Create module element-web-module-banner
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# @element-hq/element-web-module-banner
|
||||
|
||||
Banner module for Element Web.
|
||||
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 |
|
||||
| menu | `Menu` | Data to render in the banner menu |
|
||||
|
||||
The `Menu` type is fulfilled by the following discriminated union:
|
||||
|
||||
### Univention menu
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| type | "univention" | The type for this menu config |
|
||||
| ics_url | string | URL to the UCS Intercom Service, https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints |
|
||||
|
||||
### Static menu
|
||||
|
||||
| Key | Type | Description |
|
||||
| ---------- | ---------------- | ------------------------------------------------------------------------- |
|
||||
| type | "static" | The type for this menu config |
|
||||
| logo_url | string, optional | URL to the logo to render in the menu, defaults to banner logo if omitted |
|
||||
| categories | `[]Category` | Categories to render in the menu |
|
||||
|
||||
The `Category` type is fulfilled by the following interface:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----- | -------- | -------------------------------- |
|
||||
| name | string | The name of this category |
|
||||
| links | `[]Link` | Links to render in this category |
|
||||
|
||||
The `Link` type is fulfilled by the following interface:
|
||||
|
||||
| Key | Type | Description |
|
||||
| -------- | ---------------- | --------------------------------------- |
|
||||
| icon_uri | string | URL to the icon to render for this link |
|
||||
| name | string | The name to render for this link |
|
||||
| link_url | string | The URL to link to |
|
||||
| target | string, optional | The `target` to use for this link |
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-banner",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"prepare": "vite build",
|
||||
"lint:ts": "tsc --noEmit",
|
||||
"test": "echo no tests yet"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-web-module-api": "^0.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^19",
|
||||
"rollup-plugin-external-globals": "^0.13.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-node-polyfills": "^0.23.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"framer-motion": "^12.4.10",
|
||||
"styled-components": "^6.1.18",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { type ModuleConfig } from "./config";
|
||||
import UniventionMenu from "./Univention/Menu";
|
||||
import Menu from "./Menu";
|
||||
import { theme } from "./theme";
|
||||
import Logo from "./Logo.tsx";
|
||||
|
||||
const Root = styled.nav`
|
||||
background-color: ${({ theme }) => theme.compound.color.bgCanvasDefault};
|
||||
border-bottom: ${({ theme }) => theme.navbar.border};
|
||||
height: ${({ theme }) => theme.navbar.height};
|
||||
display: grid;
|
||||
grid-template-columns: ${({ theme }) => `${theme.navbar.triggerWidth} auto`};
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const Main = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-column: 2;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
logoUrl: string;
|
||||
href: string;
|
||||
menu: ModuleConfig["menu"];
|
||||
}
|
||||
|
||||
const Banner: FC<Props> = ({ api, logoUrl, href, menu }) => {
|
||||
let menuJsx;
|
||||
switch (menu.type) {
|
||||
case "static": {
|
||||
menuJsx = <Menu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
|
||||
break;
|
||||
}
|
||||
case "univention": {
|
||||
menuJsx = <UniventionMenu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Root>
|
||||
{menuJsx}
|
||||
<Main>
|
||||
<Logo api={api} src={logoUrl} href={href} />
|
||||
</Main>
|
||||
</Root>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner;
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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: ${({ theme }) => theme.navbar.logoHeight};
|
||||
align-self: center;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
src: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const Logo: FC<Props> = ({ api, src, href }) => {
|
||||
const img = <Image alt={api.i18n.translate("Portal logo")} src={src} />;
|
||||
|
||||
if (!href) return img;
|
||||
|
||||
return (
|
||||
<Anchor aria-label={api.i18n.translate("Show portal")} href={href}>
|
||||
{img}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { StaticConfig } from "./config";
|
||||
import { theme } from "./theme";
|
||||
import type { Api } from "@element-hq/element-web-module-api";
|
||||
import Logo from "./Logo.tsx";
|
||||
|
||||
const Sidebar = styled(motion.div)`
|
||||
padding: 16px 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: ${({ theme }): string => theme.menu.width};
|
||||
background: white;
|
||||
border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
`;
|
||||
|
||||
const SidebarHeading = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const Launcher = styled.button`
|
||||
align-items: center;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.compound.color.textPrimary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&[data-expanded="true"] {
|
||||
background-color: ${({ theme }): string => theme.color.accent};
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
/* Reset button styles */
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(238, 239, 242, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryHeading = styled.h2`
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #203257;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const LinkButton = styled.a`
|
||||
font-size: 14px;
|
||||
color: #000000;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #eeeff2;
|
||||
}
|
||||
`;
|
||||
|
||||
const LinkLogo = styled.img`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #eeeff2;
|
||||
margin-right: 8px;
|
||||
background-color: #ffffff;
|
||||
`;
|
||||
|
||||
const Overlay = styled(motion.div)`
|
||||
background-color: rgba(238, 239, 242, 0.5);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
config: StaticConfig;
|
||||
fallbackLogoUrl: string;
|
||||
}
|
||||
|
||||
const WIDTH = parseInt(theme.menu.width.slice(0, -2), 10);
|
||||
|
||||
const Category: FC<{
|
||||
data: StaticConfig["categories"][number];
|
||||
}> = ({ data }) => {
|
||||
return (
|
||||
<div>
|
||||
<CategoryHeading>{data.name}</CategoryHeading>
|
||||
{data.links.map((link) => (
|
||||
<LinkButton key={link.link_url} href={link.link_url} target={link.target ?? "_blank"}>
|
||||
<LinkLogo src={link.icon_uri} role="presentation" /> {link.name}
|
||||
</LinkButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<Launcher aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("Show menu")}>
|
||||
<svg fill="currentColor" height="16" width="16">
|
||||
<path d="M0 4h4V0H0v4Zm6 12h4v-4H6v4Zm-6 0h4v-4H0v4Zm0-6h4V6H0v4Zm6 0h4V6H6v4Zm6-10v4h4V0h-4ZM6 4h4V0H6v4Zm6 6h4V6h-4v4Zm0 6h4v-4h-4v4Z" />
|
||||
</svg>
|
||||
</Launcher>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay asChild>
|
||||
<Overlay
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
data-testid="dialog-overlay"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content asChild>
|
||||
<Sidebar
|
||||
initial={{ x: -WIDTH }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -WIDTH }}
|
||||
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
|
||||
aria-label={api.i18n.translate("Menu")}
|
||||
>
|
||||
<SidebarHeading>
|
||||
<Logo api={api} src={config.logo_url ?? fallbackLogoUrl} />
|
||||
<Dialog.Close asChild>
|
||||
<CloseButton
|
||||
aria-label={api.i18n.translate("Close menu")}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</CloseButton>
|
||||
</Dialog.Close>
|
||||
</SidebarHeading>
|
||||
{config.categories.map((category) => (
|
||||
<Category key={category.name} data={category} />
|
||||
))}
|
||||
</Sidebar>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import type { Api } from "@element-hq/element-web-module-api";
|
||||
import { StaticConfig, UniventionConfig } from "../config";
|
||||
import StaticMenu from "../Menu";
|
||||
import { fetchNavigation } from "./navigation";
|
||||
import SilentLogin from "./SilentLogin";
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
config: UniventionConfig;
|
||||
fallbackLogoUrl: string;
|
||||
}
|
||||
|
||||
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [data, setData] = useState<StaticConfig | null>();
|
||||
const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en";
|
||||
|
||||
useEffect(() => {
|
||||
let discard = false;
|
||||
|
||||
setData(null);
|
||||
fetchNavigation(config.ics_url, language).then((data) => {
|
||||
if (discard) return;
|
||||
setData(data);
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
discard = true;
|
||||
};
|
||||
}, [config, language, loggedIn]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />;
|
||||
}
|
||||
if (data) {
|
||||
return <StaticMenu api={api} config={data} fallbackLogoUrl={fallbackLogoUrl} />;
|
||||
}
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const HiddenIFrame = styled.iframe`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
icsUrl: string;
|
||||
onLoggedIn(success: boolean): void;
|
||||
}
|
||||
|
||||
const SilentLogin: FC<Props> = ({ onLoggedIn, icsUrl }) => {
|
||||
const url = new URL("silent", icsUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: MessageEvent): void => {
|
||||
if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) {
|
||||
onLoggedIn(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", listener);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener("message", listener);
|
||||
};
|
||||
}, [onLoggedIn, url.origin]);
|
||||
|
||||
// TODO title?
|
||||
return <HiddenIFrame src={url.href} title="Silent Login" />;
|
||||
};
|
||||
|
||||
export default SilentLogin;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { StaticConfig } from "../config";
|
||||
|
||||
/**
|
||||
* Univention Central Navigation API
|
||||
* https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html
|
||||
*/
|
||||
export const UniventionCentralNavigation = z.object({
|
||||
categories: z.array(
|
||||
z.object({
|
||||
identifier: z.string(),
|
||||
display_name: z.string(),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* A unique identifier for the navigation item.
|
||||
*/
|
||||
identifier: z.string(),
|
||||
/**
|
||||
* The URL to the icon in SVG format.
|
||||
*/
|
||||
icon_url: z.string(),
|
||||
/**
|
||||
* The label of the link.
|
||||
*/
|
||||
display_name: z.string(),
|
||||
/**
|
||||
* The destination URL of the link.
|
||||
*/
|
||||
link: z.string(),
|
||||
/**
|
||||
* The browsing context in which the browser opens the link.
|
||||
* Corresponds to the `target` property of `<a>` tags in HTML.
|
||||
*/
|
||||
target: z.string(),
|
||||
/**
|
||||
* It’s usually empty.
|
||||
*/
|
||||
keywords: z.object({}).optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type UniventionCentralNavigation = z.infer<typeof UniventionCentralNavigation>;
|
||||
|
||||
function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig {
|
||||
return {
|
||||
type: "static",
|
||||
categories: navigation.categories.map((category) => ({
|
||||
name: category.display_name,
|
||||
links: category.entries.map((entry) => ({
|
||||
icon_uri: entry.icon_url,
|
||||
name: entry.display_name,
|
||||
link_url: entry.link,
|
||||
target: entry.target,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNavigation(icsUrl: string, language: string): Promise<StaticConfig> {
|
||||
const url = new URL("navigation.json", icsUrl);
|
||||
url.search = `?language=${language}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch navigation: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const config = await UniventionCentralNavigation.parseAsync(data);
|
||||
return navigationToConfig(config);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const StaticConfig = z.object({
|
||||
type: z.literal("static"),
|
||||
|
||||
/**
|
||||
* Alternative logo URL to display in the popover menu.
|
||||
* Will use the main logo url if omitted.
|
||||
*/
|
||||
logo_url: z.string().url().optional(),
|
||||
|
||||
/**
|
||||
* Categories of links to display in the menu.
|
||||
*/
|
||||
categories: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* The category display name
|
||||
*/
|
||||
name: z.string(),
|
||||
/**
|
||||
* List of links to display in the category
|
||||
*/
|
||||
links: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* The URL to the icon.
|
||||
*/
|
||||
icon_uri: z.string().url(),
|
||||
/**
|
||||
* The label of the link.
|
||||
*/
|
||||
name: z.string(),
|
||||
/**
|
||||
* The destination URL of the link.
|
||||
*/
|
||||
link_url: z.string().url(),
|
||||
/**
|
||||
* The browsing context in which the browser opens the link.
|
||||
*/
|
||||
target: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type StaticConfig = z.infer<typeof StaticConfig>;
|
||||
|
||||
const UniventionConfig = z.object({
|
||||
type: z.literal("univention"),
|
||||
|
||||
/**
|
||||
* Base URL to an Intercom Service
|
||||
* https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints
|
||||
*/
|
||||
ics_url: z.string().url(),
|
||||
});
|
||||
|
||||
export type UniventionConfig = z.infer<typeof UniventionConfig>;
|
||||
|
||||
export const ModuleConfig = z.object({
|
||||
/**
|
||||
* The URL of the portal logo.svg file.
|
||||
* @example `https://example.com/logo.svg`
|
||||
*/
|
||||
logo_url: z.string().url(),
|
||||
|
||||
/**
|
||||
* The URL of the portal.
|
||||
* @example `https://example.com`
|
||||
*/
|
||||
logo_link_url: z.string().url(),
|
||||
|
||||
menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]),
|
||||
});
|
||||
|
||||
export type ModuleConfig = z.infer<typeof ModuleConfig>;
|
||||
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.banner";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: ModuleConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import Translations from "./translations.json";
|
||||
import { ModuleConfig, CONFIG_KEY } from "./config";
|
||||
import Banner from "./Banner";
|
||||
import { name as ModuleName } from "../package.json";
|
||||
|
||||
class TopBarModule implements Module {
|
||||
public static readonly moduleApiVersion = "^0.2.0";
|
||||
|
||||
private config?: ModuleConfig;
|
||||
|
||||
public constructor(private api: Api) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
this.api.i18n.register(Translations);
|
||||
|
||||
try {
|
||||
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
|
||||
} catch (e) {
|
||||
console.error("Failed to init module", e);
|
||||
throw new Error(`Errors in module configuration for "${ModuleName}"`);
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
this.api.rootNode.before(div);
|
||||
|
||||
const root = this.api.createRoot(div);
|
||||
root.render(
|
||||
<Banner
|
||||
api={this.api}
|
||||
logoUrl={this.config.logo_url}
|
||||
href={this.config.logo_link_url}
|
||||
menu={this.config.menu}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TopBarModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { DefaultTheme } from "styled-components";
|
||||
|
||||
const bgCanvasDefault = "var(--cpd-color-bg-canvas-default)";
|
||||
const textActionAccent = "var(--cpd-color-text-action-accent)";
|
||||
const textPrimary = "var(--cpd-color-text-primary)";
|
||||
const iconOnSolidPrimary = "var(--cpd-color-icon-on-solid-primary)";
|
||||
|
||||
const bodyMdSemibold = "var(--cpd-font-body-md-semibold)";
|
||||
|
||||
export const theme: DefaultTheme = {
|
||||
compound: {
|
||||
color: {
|
||||
bgCanvasDefault,
|
||||
textActionAccent,
|
||||
textPrimary,
|
||||
iconOnSolidPrimary,
|
||||
},
|
||||
font: {
|
||||
bodyMdSemibold,
|
||||
},
|
||||
},
|
||||
color: {
|
||||
accent: "#571EFA", // primary/700 TODO
|
||||
},
|
||||
navbar: {
|
||||
border: "1px solid #D3D7DE", // TODO
|
||||
boxShadow: "4px 4px 12px 0 rgba(118, 131, 156, 0.6)",
|
||||
height: "60px",
|
||||
triggerWidth: "68px",
|
||||
logoHeight: "34px",
|
||||
},
|
||||
menu: {
|
||||
// TODO
|
||||
width: "235px",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Portal logo": {
|
||||
"en": "Portal logo",
|
||||
"de": "Portal Logo"
|
||||
},
|
||||
"Menu": {
|
||||
"en": "Menu",
|
||||
"de": "Menü"
|
||||
},
|
||||
"Show menu": {
|
||||
"en": "Show menu",
|
||||
"de": "Menü anzeigen"
|
||||
},
|
||||
"Close menu": {
|
||||
"en": "Close menu",
|
||||
"de": "Menü schließen"
|
||||
},
|
||||
"Show portal": {
|
||||
"en": "Show portal",
|
||||
"de": "Portal anzeigen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../../../playwright/element-web-test.ts";
|
||||
import { ModuleConfig } from "../src/config.ts";
|
||||
|
||||
test.describe("Banner", () => {
|
||||
test.use({
|
||||
displayName: "Timmy",
|
||||
page: async ({ context, page, moduleDir }, use) => {
|
||||
for (const path of ["logo.svg", "app1.png", "app2.png"]) {
|
||||
await context.route(`/${path}*`, async (route) => {
|
||||
await route.fulfill({ path: `${moduleDir}/tests/fixture/${path}` });
|
||||
});
|
||||
}
|
||||
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
|
||||
await route.fulfill({
|
||||
path: `${moduleDir}/tests/fixture/navigation.json`,
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
await context.route("http://localhost:8080/ics/silent", async (route) => {
|
||||
await route.fulfill({ path: `${moduleDir}/tests/fixture/silent/index.html`, contentType: "text/html" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => {
|
||||
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
|
||||
// We don't take a screenshot as we don't want to assert Element's styling, only our own
|
||||
});
|
||||
|
||||
const configs: ModuleConfig[] = [
|
||||
{
|
||||
logo_url: "http://localhost:8080/logo.svg",
|
||||
logo_link_url: "https://example.com/portal",
|
||||
menu: {
|
||||
type: "static",
|
||||
categories: [
|
||||
{
|
||||
name: "Applications",
|
||||
links: [
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app1.png",
|
||||
name: "App 1",
|
||||
link_url: "https://example.com/app1",
|
||||
target: "app1",
|
||||
},
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app2.png",
|
||||
name: "Riot",
|
||||
link_url: "https://riot.im/app",
|
||||
target: "riot-im",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Links",
|
||||
links: [
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app1.png",
|
||||
name: "Link",
|
||||
link_url: "https://example.com/link1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
logo_url: "http://localhost:8080/logo.svg",
|
||||
logo_link_url: "https://example.com/portal",
|
||||
menu: {
|
||||
type: "univention",
|
||||
ics_url: "http://localhost:8080/ics/",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const config of configs) {
|
||||
const type = config.menu.type;
|
||||
|
||||
test.describe(`${type} config`, () => {
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.banner": config,
|
||||
},
|
||||
});
|
||||
|
||||
test("should render", { tag: ["@screenshot"] }, async ({ page, axe }) => {
|
||||
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
|
||||
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
|
||||
|
||||
const nav = page.locator("nav");
|
||||
if (type === "univention") {
|
||||
await expect(nav).toMatchScreenshot(`${type}_nav_loading.png`);
|
||||
// The stub silent html doesn't seem to work in Playwright so send the postMessage manually
|
||||
await page.evaluate(() => {
|
||||
window.postMessage({
|
||||
loggedIn: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const trigger = page.getByLabel("Show menu");
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
// Assert the banner looks as we expect
|
||||
await expect(nav).toMatchAriaSnapshot();
|
||||
await expect(nav).toMatchScreenshot(`${type}_nav.png`);
|
||||
|
||||
// Check hover styles
|
||||
await trigger.hover();
|
||||
await expect(nav).toMatchScreenshot(`${type}_nav_hover.png`);
|
||||
|
||||
await test.step("open menu", async () => {
|
||||
await trigger.click();
|
||||
const app1 = page.getByText("App 1");
|
||||
await expect(app1).toHaveAttribute("href", "https://example.com/app1");
|
||||
await app1.hover();
|
||||
|
||||
// Assert the sidebar looks as we expect
|
||||
const sidebar = page.getByRole("dialog");
|
||||
await expect(sidebar).toMatchAriaSnapshot();
|
||||
await expect(page).toMatchScreenshot(`${type}_menu.png`, {
|
||||
// We exclude this as we don't want to assert Element's styling, only our own
|
||||
css: `
|
||||
#matrixchat {
|
||||
opacity: 0;
|
||||
background: orchid;
|
||||
}
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("close menu", async () => {
|
||||
await expect(axe).toHaveNoViolations();
|
||||
|
||||
// Assert it closes by clicking the overlay
|
||||
await page.getByTestId("dialog-overlay").click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14576) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px"
|
||||
width="789.322px" height="336.807px" viewBox="0 0 789.322 336.807" enable-background="new 0 0 789.322 336.807"
|
||||
xml:space="preserve">
|
||||
<path d="M8.876,7.71v321.386h23.13v7.711H0V0h32.006v7.71H8.876z"/>
|
||||
<path d="M97.989,109.594v16.264h0.463c4.338-6.191,9.563-10.998,15.684-14.406c6.117-3.402,13.129-5.11,21.027-5.11
|
||||
c7.588,0,14.521,1.475,20.793,4.415c6.274,2.945,11.038,8.131,14.291,15.567c3.56-5.265,8.4-9.913,14.521-13.94
|
||||
c6.117-4.025,13.358-6.042,21.724-6.042c6.351,0,12.234,0.776,17.66,2.325c5.418,1.549,10.065,4.027,13.938,7.434
|
||||
c3.869,3.41,6.889,7.863,9.062,13.357c2.167,5.504,3.253,12.122,3.253,19.869v80.385h-32.993v-68.074
|
||||
c0-4.025-0.154-7.82-0.465-11.385c-0.313-3.56-1.161-6.656-2.555-9.293c-1.395-2.631-3.45-4.724-6.157-6.274
|
||||
c-2.711-1.543-6.391-2.322-11.037-2.322s-8.403,0.896-11.269,2.671c-2.868,1.784-5.112,4.109-6.737,6.971
|
||||
c-1.626,2.869-2.711,6.12-3.252,9.762c-0.545,3.638-0.814,7.318-0.814,11.035v66.91h-32.991v-67.375c0-3.562-0.081-7.087-0.23-10.57
|
||||
c-0.158-3.487-0.814-6.7-1.978-9.645c-1.162-2.94-3.099-5.304-5.809-7.088c-2.711-1.775-6.699-2.671-11.965-2.671
|
||||
c-1.551,0-3.603,0.349-6.156,1.048c-2.556,0.697-5.036,2.016-7.435,3.949c-2.404,1.938-4.454,4.726-6.158,8.363
|
||||
c-1.705,3.642-2.556,8.402-2.556,14.287v69.701h-32.99V109.594H97.989z"/>
|
||||
<path d="M271.545,127.254c3.405-5.113,7.744-9.215,13.012-12.316c5.264-3.097,11.186-5.303,17.771-6.621
|
||||
c6.582-1.315,13.205-1.976,19.865-1.976c6.042,0,12.158,0.428,18.354,1.277c6.195,0.855,11.85,2.522,16.962,4.997
|
||||
c5.111,2.477,9.292,5.926,12.546,10.338c3.253,4.414,4.879,10.262,4.879,17.543v62.494c0,5.428,0.31,10.611,0.931,15.567
|
||||
c0.615,4.959,1.701,8.676,3.251,11.153h-33.455c-0.621-1.86-1.126-3.755-1.511-5.693c-0.39-1.933-0.661-3.908-0.813-5.923
|
||||
c-5.267,5.422-11.465,9.217-18.585,11.386c-7.127,2.163-14.407,3.251-21.842,3.251c-5.733,0-11.077-0.698-16.033-2.09
|
||||
c-4.958-1.395-9.293-3.562-13.01-6.51c-3.718-2.938-6.622-6.656-8.713-11.147s-3.138-9.84-3.138-16.033
|
||||
c0-6.813,1.199-12.43,3.604-16.84c2.399-4.417,5.495-7.939,9.295-10.575c3.793-2.632,8.129-4.607,13.01-5.923
|
||||
c4.878-1.315,9.795-2.358,14.752-3.137c4.957-0.772,9.835-1.393,14.638-1.857c4.801-0.466,9.062-1.164,12.779-2.093
|
||||
c3.718-0.929,6.658-2.282,8.829-4.065c2.165-1.781,3.172-4.375,3.02-7.785c0-3.56-0.58-6.389-1.742-8.479
|
||||
c-1.161-2.09-2.711-3.719-4.646-4.88c-1.937-1.161-4.183-1.936-6.737-2.325c-2.557-0.382-5.309-0.58-8.248-0.58
|
||||
c-6.506,0-11.617,1.395-15.335,4.183c-3.716,2.788-5.889,7.437-6.506,13.94h-32.991C266.2,138.793,268.133,132.362,271.545,127.254z
|
||||
M336.714,173.837c-2.09,0.696-4.337,1.275-6.736,1.741c-2.402,0.465-4.918,0.853-7.551,1.161c-2.635,0.313-5.268,0.698-7.899,1.163
|
||||
c-2.48,0.461-4.919,1.086-7.317,1.857c-2.404,0.779-4.495,1.822-6.274,3.138c-1.784,1.317-3.216,2.985-4.3,4.994
|
||||
c-1.085,2.014-1.626,4.571-1.626,7.668c0,2.94,0.541,5.422,1.626,7.431c1.084,2.017,2.558,3.604,4.416,4.765
|
||||
s4.025,1.976,6.507,2.438c2.475,0.466,5.031,0.698,7.665,0.698c6.505,0,11.537-1.082,15.103-3.253
|
||||
c3.561-2.166,6.192-4.762,7.899-7.785c1.702-3.019,2.749-6.072,3.137-9.174c0.384-3.097,0.58-5.576,0.58-7.434V170.93
|
||||
C340.548,172.172,338.806,173.139,336.714,173.837z"/>
|
||||
<path d="M461.826,109.594v22.072h-24.161v59.479c0,5.573,0.928,9.292,2.788,11.149c1.856,1.859,5.576,2.788,11.152,2.788
|
||||
c1.859,0,3.638-0.076,5.343-0.232c1.703-0.152,3.33-0.388,4.878-0.696v25.557c-2.788,0.465-5.887,0.773-9.293,0.931
|
||||
c-3.407,0.149-6.737,0.23-9.99,0.23c-5.111,0-9.953-0.35-14.521-1.048c-4.571-0.695-8.597-2.047-12.081-4.063
|
||||
c-3.486-2.011-6.236-4.88-8.248-8.597c-2.016-3.714-3.021-8.595-3.021-14.639v-70.859h-19.98v-22.072h19.98V73.582h32.992v36.012
|
||||
H461.826z"/>
|
||||
<path d="M508.989,109.594v22.306h0.465c1.546-3.72,3.636-7.163,6.272-10.341c2.634-3.172,5.652-5.885,9.06-8.131
|
||||
c3.405-2.242,7.047-3.985,10.923-5.228c3.868-1.237,7.898-1.859,12.081-1.859c2.168,0,4.566,0.39,7.202,1.163v30.67
|
||||
c-1.551-0.312-3.41-0.584-5.576-0.814c-2.17-0.233-4.26-0.35-6.274-0.35c-6.041,0-11.152,1.01-15.332,3.021
|
||||
c-4.182,2.014-7.55,4.761-10.107,8.247c-2.555,3.487-4.379,7.55-5.462,12.198c-1.083,4.645-1.625,9.682-1.625,15.102v54.133h-32.991
|
||||
V109.594H508.989z"/>
|
||||
<path d="M568.931,91.006V63.823h32.994v27.183H568.931z M601.925,109.594v120.117h-32.994V109.594H601.925z"/>
|
||||
<path d="M619.116,109.594h37.637l21.144,31.365l20.911-31.365h36.476l-39.496,56.226l44.377,63.892h-37.64l-25.093-37.87
|
||||
l-25.094,37.87H615.4l43.213-63.193L619.116,109.594z"/>
|
||||
<path d="M780.444,329.096V7.71h-23.13V0h32.008v336.807h-32.008v-7.711H780.444z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"identifier": "category1",
|
||||
"display_name": "Category 1",
|
||||
"entries": [
|
||||
{
|
||||
"identifier": "app1",
|
||||
"display_name": "App 1",
|
||||
"icon_url": "http://localhost:8080/app1.png",
|
||||
"link": "https://example.com/app1",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app2",
|
||||
"display_name": "App 2",
|
||||
"icon_url": "http://localhost:8080/app2.png",
|
||||
"link": "https://example.com/app2",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "category2",
|
||||
"display_name": "Category 2",
|
||||
"entries": [
|
||||
{
|
||||
"identifier": "app3",
|
||||
"display_name": "App 3",
|
||||
"icon_url": "http://localhost:8080/app1.png",
|
||||
"link": "https://example.com/app3",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app4",
|
||||
"display_name": "App 4",
|
||||
"icon_url": "http://localhost:8080/app2.png",
|
||||
"link": "https://example.com/app4",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Univention Silent Login</title>
|
||||
<script type="text/javascript">
|
||||
window.postMessage({
|
||||
loggedIn: true,
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.tsx"),
|
||||
name: "element-web-module-banner",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: ["react"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
nodePolyfills({
|
||||
include: ["events"],
|
||||
}),
|
||||
externalGlobals({
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
process: { env: {} },
|
||||
},
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
"test": "echo no tests yet"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-web-module-api": "^0.1.0",
|
||||
"@element-hq/element-web-module-api": "^0.2.0",
|
||||
"@nordeck/element-web-guest-module": "^2.0.0",
|
||||
"@nordeck/element-web-opendesk-module": "^0.5.0",
|
||||
"@nordeck/element-web-widget-lifecycle-module": "^1.0.1",
|
||||
|
||||
Reference in New Issue
Block a user