diff --git a/knip.ts b/knip.ts index afcbacf3d4e..4fb81f2c36c 100644 --- a/knip.ts +++ b/knip.ts @@ -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/**"], diff --git a/modules/banner/README.md b/modules/banner/README.md index 6b85fad17ab..93f5283d8b2 100644 --- a/modules/banner/README.md +++ b/modules/banner/README.md @@ -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: @@ -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 diff --git a/modules/banner/e2e/banner.spec.ts b/modules/banner/e2e/banner.spec.ts index ecb8c583e80..e6024b3adcf 100644 --- a/modules/banner/e2e/banner.spec.ts +++ b/modules/banner/e2e/banner.spec.ts @@ -60,7 +60,7 @@ test.describe("Banner", () => { const configs: input[] = [ { logo_url: "http://localhost:8080/logo.svg", - logo_link_url: "https://example.com/portal", + heading_href: "https://example.com/portal", menu: { type: "static", categories: [ @@ -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", @@ -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", @@ -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", diff --git a/modules/banner/package.json b/modules/banner/package.json index 5b655b4fff9..fbf636ba746 100644 --- a/modules/banner/package.json +++ b/modules/banner/package.json @@ -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", @@ -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:", diff --git a/modules/banner/src/Banner.test.tsx b/modules/banner/src/Banner.test.tsx new file mode 100644 index 00000000000..66b763ea7a9 --- /dev/null +++ b/modules/banner/src/Banner.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + const link = document.querySelector('a[href="https://example.com"]'); + expect(link).not.toBeNull(); + expect(within(link as HTMLElement).getByRole("heading", { name: "My Portal" })).toBeInTheDocument(); + }); +}); diff --git a/modules/banner/src/Banner.tsx b/modules/banner/src/Banner.tsx index 112470d4e85..b1b9df782b8 100644 --- a/modules/banner/src/Banner.tsx +++ b/modules/banner/src/Banner.tsx @@ -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; @@ -53,15 +54,22 @@ const Banner: FC = ({ api, logoUrl, href, menu, title }) => { } } - return ( - - {menuJsx} - - - + let headingJsx = ( + <> + {title} + + ); + if (href) { + headingJsx = {headingJsx}; + } + + return ( + + {menuJsx} + {headingJsx} ); }; diff --git a/modules/banner/src/Logo.test.tsx b/modules/banner/src/Logo.test.tsx new file mode 100644 index 00000000000..1cba24cf5a3 --- /dev/null +++ b/modules/banner/src/Logo.test.tsx @@ -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(); + + const img = screen.getByRole("img", { name: "logo_alt" }); + expect(img).toHaveAttribute("src", "https://example.com/logo.png"); + }); +}); diff --git a/modules/banner/src/Logo.tsx b/modules/banner/src/Logo.tsx index 485f5afbbfa..9e369a763c6 100644 --- a/modules/banner/src/Logo.tsx +++ b/modules/banner/src/Logo.tsx @@ -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; }>` @@ -24,19 +20,10 @@ interface Props { api: Api; src: string; height?: string; - href?: string; } -const Logo: FC = ({ api, src, href, height }) => { - const img = {api.i18n.translate("logo_alt")}; - - if (!href) return img; - - return ( - - {img} - - ); +const Logo: FC = ({ api, src, height }) => { + return {api.i18n.translate("logo_alt")}; }; export default Logo; diff --git a/modules/banner/src/Menu.test.tsx b/modules/banner/src/Menu.test.tsx new file mode 100644 index 00000000000..b4c5adc32b1 --- /dev/null +++ b/modules/banner/src/Menu.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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); + }); +}); diff --git a/modules/banner/src/Menu.tsx b/modules/banner/src/Menu.tsx index 7afb0ca9bde..9d32950fc79 100644 --- a/modules/banner/src/Menu.tsx +++ b/modules/banner/src/Menu.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { type ComponentProps, type FC, type JSX, useState } from "react"; +import { type FC, type JSX, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import * as Dialog from "@radix-ui/react-dialog"; import styled, { useTheme } from "styled-components"; @@ -49,7 +49,7 @@ const Trigger = styled.button` width: ${({ theme }): string => theme.triggerWidth}; &:hover, - &:focus { + &:focus-visible { background-color: ${({ theme }): string => theme.triggerBackgroundColorHover}; color: ${({ theme }): string => theme.triggerColorContrast}; } @@ -79,7 +79,7 @@ const CloseButton = styled.button` border-radius: 8px; &:hover, - &:focus { + &:focus-visible { background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; } @@ -100,7 +100,7 @@ const CategoryHeading = styled.h2` const LinkButton = styled.a` font-size: 14px; - color: var(--cpd-color-text-action-primary); + color: ${({ theme }): string => theme.menuButtonColor}; font-weight: var(--cpd-font-weight-medium); display: flex; border-radius: 8px; @@ -108,11 +108,11 @@ const LinkButton = styled.a` align-items: center; &:link { - color: var(--cpd-color-text-action-primary); + color: inherit; } &:hover, - &:focus { + &:focus-visible { background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; } @@ -179,9 +179,7 @@ const Menu: FC = ({ api, config, fallbackLogoUrl }) => { const [open, setOpen] = useState(false); let content: JSX.Element; - const logoProps: Omit, "api"> = { - src: fallbackLogoUrl, - }; + let logoJsx: JSX.Element | undefined; if (config instanceof Error) { content = {api.i18n.translate("univention_error")}; @@ -193,11 +191,16 @@ const Menu: FC = ({ api, config, fallbackLogoUrl }) => { ))} ); - if (config.logo_url) { - logoProps.src = config.logo_url; + logoJsx = ( + + ); + if (config.logo_href) { + logoJsx = {logoJsx}; } - logoProps.height = config.logo_height !== undefined ? `${config.logo_height}px` : undefined; - logoProps.href = config.logo_href; } else { content = ( @@ -236,7 +239,7 @@ const Menu: FC = ({ api, config, fallbackLogoUrl }) => { > - + {logoJsx} ; export const ModuleConfig = z.object({ /** - * The URL of the portal logo.svg file. + * The URL of the logo to show in the banner. * @example `https://example.com/logo.svg` */ logo_url: z.url(), /** - * The URL of the portal. + * The URL to linkify the logo + title heading with. * @example `https://example.com` */ - logo_link_url: z.url(), + heading_href: z.url(), /** * The title to show to the right of the Logo diff --git a/modules/banner/src/index.tsx b/modules/banner/src/index.tsx index 4b65373f8c7..63f8f736434 100644 --- a/modules/banner/src/index.tsx +++ b/modules/banner/src/index.tsx @@ -49,7 +49,7 @@ class BannerModule implements Module { diff --git a/modules/banner/src/setupTests.ts b/modules/banner/src/setupTests.ts new file mode 100644 index 00000000000..4a4fc46fb19 --- /dev/null +++ b/modules/banner/src/setupTests.ts @@ -0,0 +1,12 @@ +/* +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 "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); diff --git a/modules/banner/src/theme.ts b/modules/banner/src/theme.ts index 3c715a955bf..c53dd50149a 100644 --- a/modules/banner/src/theme.ts +++ b/modules/banner/src/theme.ts @@ -12,7 +12,7 @@ export const Theme = z.object({ subheadingColor: z.prefault(z.string(), "var(--cpd-color-text-secondary)"), bannerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"), bannerHeight: z.prefault(z.string(), "60px"), - triggerWidth: z.prefault(z.string(), "68px"), + triggerWidth: z.prefault(z.string(), "69px"), triggerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-subtle-secondary)"), triggerBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-accent-hovered)"), triggerBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-accent-pressed)"), @@ -20,6 +20,7 @@ export const Theme = z.object({ triggerColorContrast: z.prefault(z.string(), "var(--cpd-color-icon-on-solid-primary)"), menuWidth: z.prefault(z.string(), "320px"), menuBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"), + menuButtonColor: z.prefault(z.string(), "var(--cpd-color-text-primary)"), menuButtonBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-hovered)"), menuButtonBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-pressed)"), }); diff --git a/modules/banner/vitest.config.ts b/modules/banner/vitest.config.ts index 3ef4e022b48..e55ca2e477d 100644 --- a/modules/banner/vitest.config.ts +++ b/modules/banner/vitest.config.ts @@ -10,5 +10,7 @@ import { defineProject } from "vitest/config"; export default defineProject({ test: { exclude: ["./e2e/**/*", "./node_modules/**/*"], + environment: "happy-dom", + setupFiles: ["src/setupTests.ts"], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e27c9ebdba..df60f7e1886 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1104,6 +1104,18 @@ importers: '@element-hq/element-web-module-api': specifier: workspace:* version: link:../../packages/module-api + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.4(@testing-library/dom@10.4.1) '@types/node': specifier: 25.9.3 version: 25.9.3 @@ -1116,6 +1128,9 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.1.1(vite@8.2.2) + happy-dom: + specifier: ^20.10.2 + version: 20.11.2 matrix-web-i18n: specifier: ^3.6.0 version: 3.6.0(supports-color@10.2.2) @@ -1125,6 +1140,9 @@ importers: react: specifier: 'catalog:' version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) typescript: specifier: 'catalog:' version: 7.0.2