Skip to content
Open
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
7 changes: 6 additions & 1 deletion knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ export default {
},
"modules/*": {
entry: ["src/index.ts{x,}!"],
project: ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,pcss}!", "!src/tests/**!", "!e2e/**!"],
project: [
"**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,pcss}!",
"!src/tests/**!",
"!e2e/**!",
"!src/setupTests.ts!",
],
},
".": {
entry: ["scripts/**", "docs/**"],
Expand Down
35 changes: 29 additions & 6 deletions modules/banner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ Allows rendering a top bar with slide out left panel menu.

Supports the following configuration options:

| Key | Type | Description |
| ------------- | ------ | ---------------------------------------------------------------------------------------------- |
| logo_url | string | URL to the logo to render in the banner |
| logo_link_url | string | URL to send the user to when clicking the logo in the banner |
| title | string | The title to render next to the logo, falls back to top level `brand` variable if unspecified. |
| menu | `Menu` | Data to render in the banner menu |
| Key | Type | Description |
| ------------ | ------ | ---------------------------------------------------------------------------------------------- |
| logo_url | string | URL to the logo to render in the banner |
| heading_href | string | URL to send the user to when clicking the logo or title in the banner |
| title | string | The title to render next to the logo, falls back to top level `brand` variable if unspecified. |
| menu | `Menu` | Data to render in the banner menu |

The `Menu` type is fulfilled by the following discriminated union:

Expand Down Expand Up @@ -52,6 +52,29 @@ The `Link` type is fulfilled by the following interface:
| logo_href | string, optional | URL to send the user to when clicking the logo in the menu |
| logo_height | number, optional | Height of the logo in pixels, defaults to 32 if omitted |

## Theming

Most of the styles can be configured via the `theme` variable in the top level config. All values are optional strings.
The following theme variables are used by this module:

| Key | Default | Description |
| -------------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
| textColor | var(--cpd-color-text-primary) | Colour of the banner text and menu button text |
| subheadingColor | var(--cpd-color-text-secondary) | Colour of the subheading text in the menu |
| bannerBackgroundColor | var(--cpd-color-bg-canvas-default) | Background colour of the banner |
| bannerHeight | 60px | Height of the banner |
| triggerWidth | 69px | Width of the trigger button |
| triggerBackgroundColor | var(--cpd-color-bg-subtle-secondary) | Background colour of the trigger button |
| triggerBackgroundColorHover | var(--cpd-color-bg-accent-hovered) | Background colour of the trigger button when hovered |
| triggerBackgroundColorPressed | var(--cpd-color-bg-accent-pressed) | Background colour of the trigger button when pressed |
| triggerColor | var(--cpd-color-icon-primary) | Colour of the trigger button icon |
| triggerColorContrast | var(--cpd-color-icon-on-solid-primary) | Colour of the trigger button icon when hovered/pressed |
| menuWidth | 320px | Width of the popover menu when open |
| menuBackgroundColor | var(--cpd-color-bg-canvas-default) | Background colour of the popover menu |
| menuButtonColor | var(--cpd-color-text-primary) | Colour of the button text inside the menu |
| menuButtonBackgroundColorHover | var(--cpd-color-bg-action-secondary-hovered) | Background colour of the buttons inside the menu when hovered |
| menuButtonBackgroundColorPressed | var(--cpd-color-bg-action-secondary-pressed) | Background colour of the buttons inside the menu when pressed |

## Copyright & License

Copyright (c) 2025 New Vector Ltd
Expand Down
8 changes: 4 additions & 4 deletions modules/banner/e2e/banner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test.describe("Banner", () => {
const configs: input<ConfigSchema>[] = [
{
logo_url: "http://localhost:8080/logo.svg",
logo_link_url: "https://example.com/portal",
heading_href: "https://example.com/portal",
menu: {
type: "static",
categories: [
Expand Down Expand Up @@ -96,7 +96,7 @@ test.describe("Banner", () => {
},
{
logo_url: "http://localhost:8080/opendesk/logomark.svg",
logo_link_url: "https://example.com/portal",
heading_href: "https://example.com/portal",
menu: {
type: "univention",
logo_url: "http://localhost:8080/opendesk/logofull.svg",
Expand Down Expand Up @@ -202,7 +202,7 @@ test.describe("Banner", () => {
config: {
"io.element.element-web-modules.banner": {
logo_url: "http://localhost:8080/opendesk/logomark.svg",
logo_link_url: "https://example.com/portal",
heading_href: "https://example.com/portal",
menu: {
type: "univention",
logo_url: "http://localhost:8080/opendesk/logofull.svg",
Expand Down Expand Up @@ -240,7 +240,7 @@ test.describe("Banner", () => {
config: {
"io.element.element-web-modules.banner": {
logo_url: "https://domain/logo1.png",
logo_link_url: "https://domain",
heading_href: "https://domain",
title: "Title",
menu: {
type: "static",
Expand Down
8 changes: 7 additions & 1 deletion modules/banner/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@element-hq/element-web-module-banner",
"private": true,
"version": "1.0.0",
"version": "2.0.0",
"type": "module",
"main": "lib/index.js",
"license": "SEE LICENSE IN README.md",
Expand All @@ -12,13 +12,19 @@
"devDependencies": {
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
"@element-hq/element-web-module-api": "workspace:*",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"happy-dom": "^20.10.2",
"matrix-web-i18n": "^3.6.0",
"matrix-widget-api": "^1.17.0",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-node-polyfills": "catalog:",
Expand Down
59 changes: 59 additions & 0 deletions modules/banner/src/Banner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { ThemeProvider } from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";

import Banner from "./Banner";
import { Theme } from "./theme";
import { type StaticConfig } from "./config";

const makeApi = (): Api => {
return {
i18n: {
translate: vi.fn((key: string) => key),
},
} as unknown as Api;
};

const menu: StaticConfig = {
type: "static",
categories: [],
};

describe("Banner", () => {
it("renders the title without a link when no href is provided", () => {
render(
<ThemeProvider theme={Theme.parse({})}>
<Banner api={makeApi()} logoUrl="https://example.com/logo.png" href="" menu={menu} title="My Portal" />
</ThemeProvider>,
);

expect(screen.getByRole("heading", { name: "My Portal" })).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});

it("wraps the logo and title in a link when href is provided", () => {
render(
<ThemeProvider theme={Theme.parse({})}>
<Banner
api={makeApi()}
logoUrl="https://example.com/logo.png"
href="https://example.com"
menu={menu}
title="My Portal"
/>
</ThemeProvider>,
);

const link = document.querySelector('a[href="https://example.com"]');
expect(link).not.toBeNull();
expect(within(link as HTMLElement).getByRole("heading", { name: "My Portal" })).toBeInTheDocument();
});
});
30 changes: 19 additions & 11 deletions modules/banner/src/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,17 @@ const Root = styled.nav`
display: flex;
gap: var(--cpd-space-3x);

a {
display: contents;
text-decoration: none;
}

h1 {
align-self: center;
color: ${({ theme }): string => theme.textColor};
}
`;

const LogoContainer = styled.div`
display: flex;
padding: var(--cpd-space-3x) 0;
`;

interface Props {
api: Api;
logoUrl: string;
Expand All @@ -53,15 +54,22 @@ const Banner: FC<Props> = ({ api, logoUrl, href, menu, title }) => {
}
}

return (
<Root>
{menuJsx}
<LogoContainer>
<Logo api={api} src={logoUrl} href={href} height="100%" />
</LogoContainer>
let headingJsx = (
<>
<Logo api={api} src={logoUrl} height="35px" />
<Heading size="sm" weight="medium" as="h1">
{title}
</Heading>
</>
);
if (href) {
headingJsx = <a href={href}>{headingJsx}</a>;
}

return (
<Root>
{menuJsx}
{headingJsx}
</Root>
);
};
Expand Down
29 changes: 29 additions & 0 deletions modules/banner/src/Logo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type Api } from "@element-hq/element-web-module-api";

import Logo from "./Logo";

const makeApi = (): Api => {
return {
i18n: {
translate: vi.fn((key: string) => key),
},
} as unknown as Api;
};

describe("Logo", () => {
it("renders an image with the translated alt text", () => {
render(<Logo api={makeApi()} src="https://example.com/logo.png" height="40px" />);

const img = screen.getByRole("img", { name: "logo_alt" });
expect(img).toHaveAttribute("src", "https://example.com/logo.png");
});
});
17 changes: 2 additions & 15 deletions modules/banner/src/Logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import { type FC } from "react";
import styled from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";

const Anchor = styled.a`
display: flex;
`;

const Image = styled.img<{
height?: string;
}>`
Expand All @@ -24,19 +20,10 @@ interface Props {
api: Api;
src: string;
height?: string;
href?: string;
}

const Logo: FC<Props> = ({ api, src, href, height }) => {
const img = <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;

if (!href) return img;

return (
<Anchor aria-label={api.i18n.translate("logo_link_label")} href={href}>
{img}
</Anchor>
);
const Logo: FC<Props> = ({ api, src, height }) => {
return <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;
};

export default Logo;
77 changes: 77 additions & 0 deletions modules/banner/src/Menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThemeProvider } from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";

import Menu from "./Menu";
import { Theme } from "./theme";
import { type StaticConfig } from "./config";

const makeApi = (): Api => {
return {
i18n: {
translate: vi.fn((key: string) => key),
},
} as unknown as Api;
};

const config: StaticConfig = {
type: "static",
categories: [
{
name: "Category",
links: [{ icon_uri: "https://example.com/icon.png", name: "Link", link_url: "https://example.com/link" }],
},
],
logo_url: "https://example.com/logo.png",
logo_height: 40,
logo_href: "https://example.com/target",
};

describe("Menu", () => {
it("opens the sidebar and renders categories, links, and a logo linked via logo_href", async () => {
const user = userEvent.setup();
render(
<ThemeProvider theme={Theme.parse({})}>
<Menu api={makeApi()} config={config} fallbackLogoUrl="https://example.com/fallback.png" />
</ThemeProvider>,
);

await user.click(screen.getByRole("button", { name: "trigger_label" }));

expect(screen.getByText("Category")).toBeInTheDocument();
const link = await screen.findByRole("link", { name: "Link" });
expect(link).toHaveAttribute("href", "https://example.com/link");

const logoLink = screen.getByRole("link", { name: "logo_alt" });
expect(logoLink).toHaveAttribute("href", "https://example.com/target");
});

it("renders the logo without a wrapping link when logo_href is not configured", async () => {
const user = userEvent.setup();
const configWithoutLogoHref: StaticConfig = { ...config, logo_href: undefined };
render(
<ThemeProvider theme={Theme.parse({})}>
<Menu
api={makeApi()}
config={configWithoutLogoHref}
fallbackLogoUrl="https://example.com/fallback.png"
/>
</ThemeProvider>,
);

await user.click(screen.getByRole("button", { name: "trigger_label" }));

const logo = await screen.findByRole("img", { name: "logo_alt" });
expect(logo.closest("a")).toBeNull();
expect(screen.getAllByRole("link")).toHaveLength(1);
});
});
Loading
Loading