Skip to content

Commit 38bf965

Browse files
authored
Tweak Banner module styling (#34903)
* Tweak Banner module styling Also improve docs + Documents `theme` key + Wraps the heading title in the anchor + Tweaks the default width of the trigger to 69px to match the space panel with its border. + Fixes focus lingering Bumps version in preparation of release * Improve coverage * Make knip happy
1 parent ae443be commit 38bf965

16 files changed

Lines changed: 287 additions & 57 deletions

knip.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ export default {
110110
},
111111
"modules/*": {
112112
entry: ["src/index.ts{x,}!"],
113-
project: ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,pcss}!", "!src/tests/**!", "!e2e/**!"],
113+
project: [
114+
"**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,pcss}!",
115+
"!src/tests/**!",
116+
"!e2e/**!",
117+
"!src/setupTests.ts!",
118+
],
114119
},
115120
".": {
116121
entry: ["scripts/**", "docs/**"],

modules/banner/README.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ Allows rendering a top bar with slide out left panel menu.
55

66
Supports the following configuration options:
77

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

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

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

55+
## Theming
56+
57+
Most of the styles can be configured via the `theme` variable in the top level config. All values are optional strings.
58+
The following theme variables are used by this module:
59+
60+
| Key | Default | Description |
61+
| -------------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
62+
| textColor | var(--cpd-color-text-primary) | Colour of the banner text and menu button text |
63+
| subheadingColor | var(--cpd-color-text-secondary) | Colour of the subheading text in the menu |
64+
| bannerBackgroundColor | var(--cpd-color-bg-canvas-default) | Background colour of the banner |
65+
| bannerHeight | 60px | Height of the banner |
66+
| triggerWidth | 69px | Width of the trigger button |
67+
| triggerBackgroundColor | var(--cpd-color-bg-subtle-secondary) | Background colour of the trigger button |
68+
| triggerBackgroundColorHover | var(--cpd-color-bg-accent-hovered) | Background colour of the trigger button when hovered |
69+
| triggerBackgroundColorPressed | var(--cpd-color-bg-accent-pressed) | Background colour of the trigger button when pressed |
70+
| triggerColor | var(--cpd-color-icon-primary) | Colour of the trigger button icon |
71+
| triggerColorContrast | var(--cpd-color-icon-on-solid-primary) | Colour of the trigger button icon when hovered/pressed |
72+
| menuWidth | 320px | Width of the popover menu when open |
73+
| menuBackgroundColor | var(--cpd-color-bg-canvas-default) | Background colour of the popover menu |
74+
| menuButtonColor | var(--cpd-color-text-primary) | Colour of the button text inside the menu |
75+
| menuButtonBackgroundColorHover | var(--cpd-color-bg-action-secondary-hovered) | Background colour of the buttons inside the menu when hovered |
76+
| menuButtonBackgroundColorPressed | var(--cpd-color-bg-action-secondary-pressed) | Background colour of the buttons inside the menu when pressed |
77+
5578
## Copyright & License
5679

5780
Copyright (c) 2025 New Vector Ltd

modules/banner/e2e/banner.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ test.describe("Banner", () => {
6060
const configs: input<ConfigSchema>[] = [
6161
{
6262
logo_url: "http://localhost:8080/logo.svg",
63-
logo_link_url: "https://example.com/portal",
63+
heading_href: "https://example.com/portal",
6464
menu: {
6565
type: "static",
6666
categories: [
@@ -96,7 +96,7 @@ test.describe("Banner", () => {
9696
},
9797
{
9898
logo_url: "http://localhost:8080/opendesk/logomark.svg",
99-
logo_link_url: "https://example.com/portal",
99+
heading_href: "https://example.com/portal",
100100
menu: {
101101
type: "univention",
102102
logo_url: "http://localhost:8080/opendesk/logofull.svg",
@@ -202,7 +202,7 @@ test.describe("Banner", () => {
202202
config: {
203203
"io.element.element-web-modules.banner": {
204204
logo_url: "http://localhost:8080/opendesk/logomark.svg",
205-
logo_link_url: "https://example.com/portal",
205+
heading_href: "https://example.com/portal",
206206
menu: {
207207
type: "univention",
208208
logo_url: "http://localhost:8080/opendesk/logofull.svg",
@@ -240,7 +240,7 @@ test.describe("Banner", () => {
240240
config: {
241241
"io.element.element-web-modules.banner": {
242242
logo_url: "https://domain/logo1.png",
243-
logo_link_url: "https://domain",
243+
heading_href: "https://domain",
244244
title: "Title",
245245
menu: {
246246
type: "static",

modules/banner/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@element-hq/element-web-module-banner",
33
"private": true,
4-
"version": "1.0.0",
4+
"version": "2.0.0",
55
"type": "module",
66
"main": "lib/index.js",
77
"license": "SEE LICENSE IN README.md",
@@ -12,13 +12,19 @@
1212
"devDependencies": {
1313
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
1414
"@element-hq/element-web-module-api": "workspace:*",
15+
"@testing-library/dom": "^10.4.1",
16+
"@testing-library/jest-dom": "^7.0.0",
17+
"@testing-library/react": "^16.3.2",
18+
"@testing-library/user-event": "^14.6.1",
1519
"@types/node": "catalog:",
1620
"@types/react": "catalog:",
1721
"@types/react-dom": "catalog:",
1822
"@vitejs/plugin-react": "catalog:",
23+
"happy-dom": "^20.10.2",
1924
"matrix-web-i18n": "^3.6.0",
2025
"matrix-widget-api": "^1.17.0",
2126
"react": "catalog:",
27+
"react-dom": "catalog:",
2228
"typescript": "catalog:",
2329
"vite": "catalog:",
2430
"vite-plugin-node-polyfills": "catalog:",

modules/banner/src/Banner.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { describe, expect, it, vi } from "vitest";
9+
import { render, screen, within } from "@testing-library/react";
10+
import { ThemeProvider } from "styled-components";
11+
import { type Api } from "@element-hq/element-web-module-api";
12+
13+
import Banner from "./Banner";
14+
import { Theme } from "./theme";
15+
import { type StaticConfig } from "./config";
16+
17+
const makeApi = (): Api => {
18+
return {
19+
i18n: {
20+
translate: vi.fn((key: string) => key),
21+
},
22+
} as unknown as Api;
23+
};
24+
25+
const menu: StaticConfig = {
26+
type: "static",
27+
categories: [],
28+
};
29+
30+
describe("Banner", () => {
31+
it("renders the title without a link when no href is provided", () => {
32+
render(
33+
<ThemeProvider theme={Theme.parse({})}>
34+
<Banner api={makeApi()} logoUrl="https://example.com/logo.png" href="" menu={menu} title="My Portal" />
35+
</ThemeProvider>,
36+
);
37+
38+
expect(screen.getByRole("heading", { name: "My Portal" })).toBeInTheDocument();
39+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
40+
});
41+
42+
it("wraps the logo and title in a link when href is provided", () => {
43+
render(
44+
<ThemeProvider theme={Theme.parse({})}>
45+
<Banner
46+
api={makeApi()}
47+
logoUrl="https://example.com/logo.png"
48+
href="https://example.com"
49+
menu={menu}
50+
title="My Portal"
51+
/>
52+
</ThemeProvider>,
53+
);
54+
55+
const link = document.querySelector('a[href="https://example.com"]');
56+
expect(link).not.toBeNull();
57+
expect(within(link as HTMLElement).getByRole("heading", { name: "My Portal" })).toBeInTheDocument();
58+
});
59+
});

modules/banner/src/Banner.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ const Root = styled.nav`
2222
display: flex;
2323
gap: var(--cpd-space-3x);
2424
25+
a {
26+
display: contents;
27+
text-decoration: none;
28+
}
29+
2530
h1 {
2631
align-self: center;
32+
color: ${({ theme }): string => theme.textColor};
2733
}
2834
`;
2935

30-
const LogoContainer = styled.div`
31-
display: flex;
32-
padding: var(--cpd-space-3x) 0;
33-
`;
34-
3536
interface Props {
3637
api: Api;
3738
logoUrl: string;
@@ -53,15 +54,22 @@ const Banner: FC<Props> = ({ api, logoUrl, href, menu, title }) => {
5354
}
5455
}
5556

56-
return (
57-
<Root>
58-
{menuJsx}
59-
<LogoContainer>
60-
<Logo api={api} src={logoUrl} href={href} height="100%" />
61-
</LogoContainer>
57+
let headingJsx = (
58+
<>
59+
<Logo api={api} src={logoUrl} height="35px" />
6260
<Heading size="sm" weight="medium" as="h1">
6361
{title}
6462
</Heading>
63+
</>
64+
);
65+
if (href) {
66+
headingJsx = <a href={href}>{headingJsx}</a>;
67+
}
68+
69+
return (
70+
<Root>
71+
{menuJsx}
72+
{headingJsx}
6573
</Root>
6674
);
6775
};

modules/banner/src/Logo.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { describe, expect, it, vi } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import { type Api } from "@element-hq/element-web-module-api";
11+
12+
import Logo from "./Logo";
13+
14+
const makeApi = (): Api => {
15+
return {
16+
i18n: {
17+
translate: vi.fn((key: string) => key),
18+
},
19+
} as unknown as Api;
20+
};
21+
22+
describe("Logo", () => {
23+
it("renders an image with the translated alt text", () => {
24+
render(<Logo api={makeApi()} src="https://example.com/logo.png" height="40px" />);
25+
26+
const img = screen.getByRole("img", { name: "logo_alt" });
27+
expect(img).toHaveAttribute("src", "https://example.com/logo.png");
28+
});
29+
});

modules/banner/src/Logo.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ import { type FC } from "react";
99
import styled from "styled-components";
1010
import { type Api } from "@element-hq/element-web-module-api";
1111

12-
const Anchor = styled.a`
13-
display: flex;
14-
`;
15-
1612
const Image = styled.img<{
1713
height?: string;
1814
}>`
@@ -24,19 +20,10 @@ interface Props {
2420
api: Api;
2521
src: string;
2622
height?: string;
27-
href?: string;
2823
}
2924

30-
const Logo: FC<Props> = ({ api, src, href, height }) => {
31-
const img = <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;
32-
33-
if (!href) return img;
34-
35-
return (
36-
<Anchor aria-label={api.i18n.translate("logo_link_label")} href={href}>
37-
{img}
38-
</Anchor>
39-
);
25+
const Logo: FC<Props> = ({ api, src, height }) => {
26+
return <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;
4027
};
4128

4229
export default Logo;

modules/banner/src/Menu.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { describe, expect, it, vi } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import userEvent from "@testing-library/user-event";
11+
import { ThemeProvider } from "styled-components";
12+
import { type Api } from "@element-hq/element-web-module-api";
13+
14+
import Menu from "./Menu";
15+
import { Theme } from "./theme";
16+
import { type StaticConfig } from "./config";
17+
18+
const makeApi = (): Api => {
19+
return {
20+
i18n: {
21+
translate: vi.fn((key: string) => key),
22+
},
23+
} as unknown as Api;
24+
};
25+
26+
const config: StaticConfig = {
27+
type: "static",
28+
categories: [
29+
{
30+
name: "Category",
31+
links: [{ icon_uri: "https://example.com/icon.png", name: "Link", link_url: "https://example.com/link" }],
32+
},
33+
],
34+
logo_url: "https://example.com/logo.png",
35+
logo_height: 40,
36+
logo_href: "https://example.com/target",
37+
};
38+
39+
describe("Menu", () => {
40+
it("opens the sidebar and renders categories, links, and a logo linked via logo_href", async () => {
41+
const user = userEvent.setup();
42+
render(
43+
<ThemeProvider theme={Theme.parse({})}>
44+
<Menu api={makeApi()} config={config} fallbackLogoUrl="https://example.com/fallback.png" />
45+
</ThemeProvider>,
46+
);
47+
48+
await user.click(screen.getByRole("button", { name: "trigger_label" }));
49+
50+
expect(screen.getByText("Category")).toBeInTheDocument();
51+
const link = await screen.findByRole("link", { name: "Link" });
52+
expect(link).toHaveAttribute("href", "https://example.com/link");
53+
54+
const logoLink = screen.getByRole("link", { name: "logo_alt" });
55+
expect(logoLink).toHaveAttribute("href", "https://example.com/target");
56+
});
57+
58+
it("renders the logo without a wrapping link when logo_href is not configured", async () => {
59+
const user = userEvent.setup();
60+
const configWithoutLogoHref: StaticConfig = { ...config, logo_href: undefined };
61+
render(
62+
<ThemeProvider theme={Theme.parse({})}>
63+
<Menu
64+
api={makeApi()}
65+
config={configWithoutLogoHref}
66+
fallbackLogoUrl="https://example.com/fallback.png"
67+
/>
68+
</ThemeProvider>,
69+
);
70+
71+
await user.click(screen.getByRole("button", { name: "trigger_label" }));
72+
73+
const logo = await screen.findByRole("img", { name: "logo_alt" });
74+
expect(logo.closest("a")).toBeNull();
75+
expect(screen.getAllByRole("link")).toHaveLength(1);
76+
});
77+
});

0 commit comments

Comments
 (0)