feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
|
||||
extends: [
|
||||
"plugin:matrix-org/react",
|
||||
"plugin:matrix-org/a11y",
|
||||
"plugin:matrix-org/typescript",
|
||||
"plugin:matrix-org/react",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
// Bind or arrow functions in props causes performance issues (but we
|
||||
// currently use them in some places).
|
||||
// It's disabled here, but we should using it sparingly.
|
||||
"react/jsx-no-bind": "off",
|
||||
"react/jsx-key": ["error"],
|
||||
"matrix-org/require-copyright-header": "error",
|
||||
"react-compiler/react-compiler": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "react",
|
||||
importNames: ["act"],
|
||||
message: "Please use @test-utils instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
|
||||
// We're okay being explicit at the moment
|
||||
// "@typescript-eslint/no-empty-interface": "off",
|
||||
// We'd rather not do this but we do
|
||||
// "@typescript-eslint/ban-ts-comment": "off",
|
||||
// We're okay with assertion errors when we ask for them
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// We do this sometimes to brand interfaces
|
||||
allowInterfaces: "with-single-extends",
|
||||
},
|
||||
],
|
||||
"storybook/meta-satisfies-type": "error",
|
||||
|
||||
"react/forbid-elements": [
|
||||
"error",
|
||||
{
|
||||
forbid: [
|
||||
{ element: "h1", message: "Use Compound <Heading> instead" },
|
||||
{ element: "h2", message: "Use Compound <Heading> instead" },
|
||||
{ element: "h3", message: "Use Compound <Heading> instead" },
|
||||
{ element: "h4", message: "Use Compound <Heading> instead" },
|
||||
{ element: "h5", message: "Use Compound <Heading> instead" },
|
||||
{ element: "h6", message: "Use Compound <Heading> instead" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["src/**/*.test.{ts,tsx}", "src/**/*.stories.tsx"],
|
||||
rules: {
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"react/forbid-elements": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
# Ignore test failure screenshots
|
||||
/src/**/__screenshots__/
|
||||
|
||||
# Ignore vis diffs & local baseline
|
||||
/__vis__/**/__diffs__
|
||||
/__vis__/**/__results__
|
||||
/__vis__/local
|
||||
|
||||
# Ignore coverage report
|
||||
/coverage/
|
||||
# Ignore generated docs
|
||||
typedoc
|
||||
# Build storybook
|
||||
storybook-static
|
||||
@@ -0,0 +1,5 @@
|
||||
// Even though this (at time of writing) is identical Element Web's
|
||||
// .prettierrc.js, shared components needs its own because otherwise
|
||||
// this refers to element web's copy of eslint-plugin-matrix-org which
|
||||
// would require element-web's modules to be installed.
|
||||
module.exports = require("eslint-plugin-matrix-org/.prettierrc.js");
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { create } from "storybook/theming";
|
||||
|
||||
export default create({
|
||||
base: "light",
|
||||
|
||||
// Colors
|
||||
textColor: "#1b1d22",
|
||||
colorSecondary: "#111111",
|
||||
|
||||
// UI
|
||||
appBg: "#ffffff",
|
||||
appContentBg: "#ffffff",
|
||||
|
||||
// Toolbar
|
||||
barBg: "#ffffff",
|
||||
|
||||
brandTitle: "Web Shared Components",
|
||||
brandUrl: "https://github.com/element-hq/element-web/tree/develop/packages/shared-components",
|
||||
brandTarget: "_self",
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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.
|
||||
*/
|
||||
|
||||
/* Shared cascade order: Compound tokens, Compound Web, shared components, then app overrides. */
|
||||
@layer compound-tokens, compound-web, shared-components, app-web;
|
||||
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound-tokens);
|
||||
@import url("@vector-im/compound-web/dist/style.css") layer(compound-web);
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 { Addon, types, useGlobals } from "storybook/manager-api";
|
||||
import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
|
||||
import React from "react";
|
||||
import { GlobeIcon } from "@storybook/icons";
|
||||
|
||||
const languages: string[] = JSON.parse(process.env.STORYBOOK_LANGUAGES!);
|
||||
|
||||
/**
|
||||
* Returns the title of a language in the user's locale.
|
||||
*/
|
||||
function languageTitle(language: string): string {
|
||||
const normalisedLang = language.toLowerCase().replace("_", "-");
|
||||
return new Intl.DisplayNames([normalisedLang], { type: "language", style: "short" }).of(normalisedLang) || language;
|
||||
}
|
||||
|
||||
export const languageAddon: Addon = {
|
||||
title: "Language Selector",
|
||||
type: types.TOOL,
|
||||
render: ({ active }) => {
|
||||
const [globals, updateGlobals] = useGlobals();
|
||||
const selectedLanguage = globals.language || "en";
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
placement="top"
|
||||
trigger="click"
|
||||
closeOnOutsideClick
|
||||
tooltip={({ onHide }) => {
|
||||
return (
|
||||
<TooltipLinkList
|
||||
links={languages.map((language) => ({
|
||||
id: language,
|
||||
title: languageTitle(language),
|
||||
active: selectedLanguage === language,
|
||||
onClick: async () => {
|
||||
// Update the global state with the selected language
|
||||
updateGlobals({ language });
|
||||
onHide();
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<IconButton title="Language">
|
||||
<GlobeIcon />
|
||||
{languageTitle(selectedLanguage)}
|
||||
</IconButton>
|
||||
</WithTooltip>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 type { StorybookConfig } from "@storybook/react-vite";
|
||||
import fs from "node:fs";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import { mergeConfig, normalizePath, type Plugin } from "vite";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = normalizePath(join(__dirname, "..", "src"));
|
||||
const sharedComponentsLayer = "shared-components";
|
||||
|
||||
// Get a list of available languages so the language selector can display them at runtime
|
||||
const languageFiles = fs.readdirSync(join(__dirname, "..", "src", "i18n", "strings")).map((f) => f.slice(0, -5));
|
||||
|
||||
const languages: Record<string, string> = {};
|
||||
for (const lang of languageFiles) {
|
||||
const normalizedLanguage = lang.toLowerCase().replace("_", "-");
|
||||
const languageParts = normalizedLanguage.split("-");
|
||||
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
|
||||
languages[languageParts[0]] = `${lang}.json`;
|
||||
} else {
|
||||
languages[normalizedLanguage] = `${lang}.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
|
||||
}
|
||||
|
||||
function layerSharedComponentCssModules(): Plugin {
|
||||
return {
|
||||
name: "element-web-shared-components-storybook-css-layer",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const cssPath = normalizePath(id.split("?")[0]);
|
||||
if (!cssPath.startsWith(srcRoot) || !cssPath.endsWith(".module.css")) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
code: `@layer ${sharedComponentsLayer} {\n${code}\n}\n`,
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-designs",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-vitest",
|
||||
getAbsolutePath("storybook-addon-vis"),
|
||||
],
|
||||
framework: "@storybook/react-vite",
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
reactDocgenTypescriptOptions: {
|
||||
// The default exclude is ["**/**.stories.tsx"] which prevents
|
||||
// docgen from extracting snapshot field descriptions from wrapper
|
||||
// components defined in story files.
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
async viteFinal(config) {
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
layerSharedComponentCssModules(),
|
||||
// Needed for counterpart to work
|
||||
nodePolyfills({ include: ["util"], globals: { global: false } }),
|
||||
{
|
||||
name: "language-middleware",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === "/i18n/languages.json") {
|
||||
// Dynamically generate a languages.json file based on what files are available
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(languages));
|
||||
} else if (req.url === "/usercontent/" || req.url === "/usercontent") {
|
||||
// Mock usercontent endpoint used by encrypted download iframes.
|
||||
res.end("This is where /usercontent/ is loaded.");
|
||||
} else if (req.url?.startsWith("/i18n/")) {
|
||||
// Serve the individual language files, which annoyingly can't be a simple
|
||||
// static dir because the directory structure in src doesn't match what
|
||||
// the app requests.
|
||||
const langFile = req.url.split("/").pop();
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
fs.createReadStream(`src/i18n/strings/${langFile}`).pipe(res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
server: {
|
||||
allowedHosts: ["localhost", ".docker.internal"],
|
||||
},
|
||||
});
|
||||
},
|
||||
refs: {
|
||||
"compound-web": {
|
||||
title: "Compound Web",
|
||||
url: "https://element-hq.github.io/compound-web/",
|
||||
},
|
||||
},
|
||||
env: (config) => ({
|
||||
...config,
|
||||
STORYBOOK_LANGUAGES: JSON.stringify(Object.keys(languages)),
|
||||
}),
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { addons } from "storybook/manager-api";
|
||||
import ElementTheme from "./ElementTheme";
|
||||
import { languageAddon } from "./languageAddon";
|
||||
|
||||
addons.setConfig({
|
||||
theme: ElementTheme,
|
||||
});
|
||||
|
||||
addons.register("elementhq/language", () => addons.add("language", languageAddon));
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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.
|
||||
*/
|
||||
|
||||
.docs-story {
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
/* Username color classes - these are defined in the main app's _common.pcss
|
||||
but need to be available in Storybook for components that use colorClass */
|
||||
.mx_Username_color1 {
|
||||
color: var(--cpd-color-text-decorative-1);
|
||||
}
|
||||
|
||||
.mx_Username_color2 {
|
||||
color: var(--cpd-color-text-decorative-2);
|
||||
}
|
||||
|
||||
.mx_Username_color3 {
|
||||
color: var(--cpd-color-text-decorative-3);
|
||||
}
|
||||
|
||||
.mx_Username_color4 {
|
||||
color: var(--cpd-color-text-decorative-4);
|
||||
}
|
||||
|
||||
.mx_Username_color5 {
|
||||
color: var(--cpd-color-text-decorative-5);
|
||||
}
|
||||
|
||||
.mx_Username_color6 {
|
||||
color: var(--cpd-color-text-decorative-6);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 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 type { ArgTypes, Decorator, Preview, ReactRenderer, StrictArgs } from "@storybook/react-vite";
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
|
||||
import "./compound.css";
|
||||
import "./preview.css";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import type { StoryContext } from "storybook/internal/csf";
|
||||
|
||||
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
|
||||
import { setLanguage } from "../src/core/i18n/i18n";
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
name: "Theme",
|
||||
description: "Global theme for components",
|
||||
toolbar: {
|
||||
icon: "circlehollow",
|
||||
title: "Theme",
|
||||
items: [
|
||||
{ title: "System", value: "system", icon: "browser" },
|
||||
{ title: "Light", value: "light", icon: "sun" },
|
||||
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
|
||||
{ title: "Dark", value: "dark", icon: "moon" },
|
||||
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
|
||||
],
|
||||
},
|
||||
},
|
||||
language: {
|
||||
name: "Language",
|
||||
description: "Global language for components",
|
||||
},
|
||||
eventLayout: {
|
||||
name: "Event layout",
|
||||
description: "Global event layout for timeline components",
|
||||
toolbar: {
|
||||
icon: "component",
|
||||
title: "Event layout",
|
||||
items: [
|
||||
{ title: "Group", value: "group" },
|
||||
{ title: "Bubble", value: "bubble" },
|
||||
{ title: "IRC", value: "irc" },
|
||||
],
|
||||
},
|
||||
},
|
||||
eventDensity: {
|
||||
name: "Event density",
|
||||
description: "Global event density for timeline components",
|
||||
toolbar: {
|
||||
icon: "listunordered",
|
||||
title: "Event density",
|
||||
items: [
|
||||
{ title: "Default", value: "default" },
|
||||
{ title: "Compact", value: "compact" },
|
||||
],
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: "system",
|
||||
language: "en",
|
||||
eventLayout: "group",
|
||||
eventDensity: "default",
|
||||
},
|
||||
} satisfies ArgTypes;
|
||||
|
||||
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
|
||||
|
||||
const ThemeSwitcher: React.FC<{
|
||||
theme: string;
|
||||
}> = ({ theme }) => {
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.classList.remove(...allThemesClasses);
|
||||
if (theme !== "system") {
|
||||
document.documentElement.classList.add(`cpd-theme-${theme}`);
|
||||
}
|
||||
return () => document.documentElement.classList.remove(...allThemesClasses);
|
||||
}, [theme]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const withThemeProvider: Decorator = (Story, context) => {
|
||||
return (
|
||||
<>
|
||||
<ThemeSwitcher theme={context.globals.theme} />
|
||||
<Story />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
async function languageLoader(context: StoryContext<ReactRenderer, StrictArgs>): Promise<void> {
|
||||
await setLanguage(context.globals.language);
|
||||
}
|
||||
|
||||
const withTooltipProvider: Decorator = (Story) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const withI18nProvider: Decorator = (Story) => {
|
||||
return (
|
||||
<I18nContext.Provider value={new I18nApi()}>
|
||||
<Story />
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const withEventPresentationProvider: Decorator = (Story, context) => {
|
||||
return (
|
||||
<EventPresentationProvider
|
||||
value={{
|
||||
layout: context.globals.eventLayout as EventLayout,
|
||||
density: context.globals.eventDensity as EventDensity,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</EventPresentationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const preview = {
|
||||
tags: ["autodocs", "snapshot"],
|
||||
initialGlobals: {
|
||||
theme: "system",
|
||||
language: "en",
|
||||
eventLayout: "group",
|
||||
eventDensity: "default",
|
||||
},
|
||||
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
method: "alphabetical",
|
||||
},
|
||||
},
|
||||
a11y: {
|
||||
/*
|
||||
* Configure test behavior
|
||||
* See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior
|
||||
*/
|
||||
test: "error",
|
||||
},
|
||||
},
|
||||
loaders: [languageLoader],
|
||||
} satisfies Preview;
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
import { setProjectAnnotations } from "@storybook/react-vite";
|
||||
import { vis, visAnnotations } from "storybook-addon-vis/vitest-setup";
|
||||
|
||||
import * as projectAnnotations from "./preview.tsx";
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations, visAnnotations]);
|
||||
|
||||
vis.setup({
|
||||
async auto() {
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("type", "text/css");
|
||||
style.appendChild(
|
||||
document.createTextNode(`
|
||||
/* Inhibit all animations for the screenshot to be more stable */
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
}
|
||||
/*
|
||||
* Mask spinner for video overlay during screenshot generation on playwright tests.
|
||||
*/
|
||||
[data-video-body-mask-target] {
|
||||
position: relative;
|
||||
}
|
||||
[data-video-body-mask-target]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 50%;
|
||||
inset-block-start: 50%;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 999px;
|
||||
background: #ff4fcf;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Hide all storybook elements */
|
||||
.sb-wrapper {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`),
|
||||
);
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copies the component description and props documentation from a View's
|
||||
* `__docgenInfo` (injected at build time by Storybook's react-docgen-typescript
|
||||
* Vite plugin) onto the story wrapper component.
|
||||
*
|
||||
* This lets Storybook's default `extractComponentDescription` pick up the
|
||||
* View's JSDoc and display per-field descriptions in the ArgTypes table.
|
||||
*
|
||||
* **Important:** the wrapper must be defined as a named variable *before*
|
||||
* being passed here so that react-docgen-typescript can extract its props.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const MyViewWrapperImpl = (props: MyViewProps) => {
|
||||
* const vm = useMockedViewModel(props, {});
|
||||
* return <MyView vm={vm} />;
|
||||
* };
|
||||
* const MyViewWrapper = withViewDocs(MyViewWrapperImpl, MyView);
|
||||
* ```
|
||||
*/
|
||||
export function withViewDocs<T extends (...args: never[]) => unknown>(wrapper: T, view: object): T {
|
||||
const viewInfo = (view as { __docgenInfo?: DocgenInfo }).__docgenInfo;
|
||||
const viewDescription = viewInfo?.description;
|
||||
if (!viewDescription) return wrapper;
|
||||
|
||||
// The wrapper must be defined as a named variable (not inline) so that
|
||||
// react-docgen-typescript can extract its props. The docgen Vite plugin
|
||||
// appends a `Wrapper.__docgenInfo = { … }` assignment at the *end* of the
|
||||
// module, which runs **after** this function. We install a setter trap so
|
||||
// that the View's description is merged into the generated info.
|
||||
let stored: DocgenInfo | undefined = (wrapper as { __docgenInfo?: DocgenInfo }).__docgenInfo;
|
||||
Object.defineProperty(wrapper, "__docgenInfo", {
|
||||
get() {
|
||||
return stored;
|
||||
},
|
||||
set(incoming: DocgenInfo) {
|
||||
stored = {
|
||||
...incoming,
|
||||
description: incoming.description || viewDescription,
|
||||
};
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
// Also apply immediately for the current state.
|
||||
stored = { ...stored, description: viewDescription };
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
interface DocgenInfo {
|
||||
description?: string;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
# @element-hq/web-shared-components
|
||||
|
||||
[Online storybook](https://shared-components-storybook.element.dev)
|
||||
|
||||
Shared React components library for Element Web, Aurora, Element
|
||||
modules... This package provides opinionated UI components built on top of the
|
||||
[Compound Design System](https://compound.element.io) and [Compound
|
||||
Web](https://github.com/element-hq/compound-web). This is not a design system
|
||||
by itself, but rather a set of larger components.
|
||||
|
||||
## Installation in a new project
|
||||
|
||||
When adding this library to a new project, as well as installing
|
||||
`@element-hq/web-shared-components` as normal, you will also need to add
|
||||
[compound-web](https://github.com/element-hq/compound-web) as a peer
|
||||
dependency:
|
||||
|
||||
```bash
|
||||
pnpm add @element-hq/web-shared-components
|
||||
pnpm add @vector-im/compound-web
|
||||
```
|
||||
|
||||
(This avoids problems where we end up with different versions of compound-web in the
|
||||
top-level project and web-shared-components).
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Import
|
||||
|
||||
Both JavaScript and CSS can be imported as follows:
|
||||
|
||||
```javascript
|
||||
import { RoomListHeaderView, useViewModel } from "@element-hq/web-shared-components";
|
||||
import "@element-hq/web-shared-components/dist/element-web-shared-components.css";
|
||||
```
|
||||
|
||||
or in CSS file:
|
||||
|
||||
```css
|
||||
@import url("@element-hq/web-shared-components");
|
||||
```
|
||||
|
||||
### Using Components
|
||||
|
||||
There are two kinds of components in this library:
|
||||
|
||||
- _regular_ react component which doesn't follow specific pattern.
|
||||
- _view_ component(MVVM pattern).
|
||||
|
||||
> [!TIP]
|
||||
> These components are available in the project storybook.
|
||||
|
||||
#### Regular Components
|
||||
|
||||
These components can be used directly by passing props. Example:
|
||||
|
||||
```tsx
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
function MyApp() {
|
||||
return <Flex align="center" />;
|
||||
}
|
||||
```
|
||||
|
||||
#### View (MVVM) Components
|
||||
|
||||
These components follow the [MVVM pattern](../../docs/MVVM.md). A ViewModel
|
||||
instance should be provided as a prop.
|
||||
|
||||
Here's a basic example:
|
||||
|
||||
```tsx
|
||||
import { ViewExample } from "@element-hq/web-shared-components";
|
||||
|
||||
function MyApp() {
|
||||
const viewModel = new ViewModelExample();
|
||||
return <ViewExample vm={viewModel} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
||||
#### Internationalization
|
||||
|
||||
- `useI18n()` - Hook for translations
|
||||
- `I18nApi` - Internationalization API utilities
|
||||
|
||||
#### Date & Time
|
||||
|
||||
- `DateUtils` - Date formatting and manipulation
|
||||
- `humanize` - Human-readable time formatting
|
||||
|
||||
#### Formatting
|
||||
|
||||
- `FormattingUtils` - Text and data formatting utilities
|
||||
- `numbers` - Number formatting utilities
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- pnpm => 10
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the library
|
||||
pnpm prepack
|
||||
```
|
||||
|
||||
### Running Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
### Write components
|
||||
|
||||
Most components should be written as [MVVM pattern](../../docs/MVVM.md) view
|
||||
components. See existing components for examples. The exceptions are low level
|
||||
components that don't need a view model.
|
||||
|
||||
### Write Storybook Stories
|
||||
|
||||
All components should have accompanying Storybook stories for documentation and visual testing. Stories are written in TypeScript using the [Component Story Format (CSF)](https://storybook.js.org/docs/api/csf).
|
||||
|
||||
Use shallow, browse-oriented story titles such as `RoomList/RoomListSearchView` or `TimelineBody/DecryptionFailureBodyView`. Do not mirror the full source path in the Storybook title.
|
||||
|
||||
#### Story File Structure
|
||||
|
||||
Place the story file next to the component with the `.stories.tsx` extension:
|
||||
|
||||
```
|
||||
MyComponent/
|
||||
├── MyComponent.tsx
|
||||
├── MyComponent.module.css
|
||||
└── MyComponent.stories.tsx
|
||||
```
|
||||
|
||||
#### Regular Component Stories
|
||||
|
||||
For regular React components (non-MVVM), create stories by defining a meta object and story variations:
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { MyComponent } from "./MyComponent";
|
||||
|
||||
const meta = {
|
||||
title: "Category/MyComponent",
|
||||
component: MyComponent,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
// Default args for all stories
|
||||
label: "Default Label",
|
||||
onClick: fn(), // Mock function for tracking interactions
|
||||
},
|
||||
} satisfies Meta<typeof MyComponent>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Default story uses the default args
|
||||
export const Default: Story = {};
|
||||
|
||||
// Override specific args for variations
|
||||
export const WithCustomLabel: Story = {
|
||||
args: {
|
||||
label: "Custom Label",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### MVVM Component Stories
|
||||
|
||||
For MVVM components, create a wrapper component that uses `useMockedViewModel` and `withViewDocs`:
|
||||
|
||||
```tsx
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MyComponentView, type MyComponentViewSnapshot, type MyComponentViewActions } from "./MyComponentView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
// Combine snapshot and actions for easier typing
|
||||
type MyComponentProps = MyComponentViewSnapshot & MyComponentViewActions;
|
||||
|
||||
// Wrapper component that creates a mocked ViewModel.
|
||||
// Must be a named variable (not inline) for docgen to extract its props.
|
||||
const MyComponentViewWrapperImpl = ({ onAction, ...rest }: MyComponentProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onAction,
|
||||
});
|
||||
return <MyComponentView vm={vm} />;
|
||||
};
|
||||
// withViewDocs copies the View's JSDoc description onto the wrapper for Storybook autodocs
|
||||
const MyComponentViewWrapper = withViewDocs(MyComponentViewWrapperImpl, MyComponentView);
|
||||
|
||||
// Must use `satisfies` (not `as` or `: Meta`) to preserve type info for docgen
|
||||
const meta = {
|
||||
title: "Category/MyComponentView",
|
||||
component: MyComponentViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
// Snapshot properties (state)
|
||||
title: "Default Title",
|
||||
isLoading: false,
|
||||
// Action properties (callbacks)
|
||||
onAction: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof MyComponentViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MyComponentViewWrapper>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Thanks to this approach, we can directly use primitives in the story arguments instead of a view model object.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Three requirements must be met for snapshot field documentation to appear in Storybook's ArgTypes table:
|
||||
>
|
||||
> 1. **Named wrapper variable** — the wrapper must be assigned to a named `const` (e.g. `MyComponentViewWrapperImpl`) before being passed to `withViewDocs`, so that `react-docgen-typescript` can extract its props.
|
||||
> 2. **`withViewDocs` call** — wraps the wrapper component with the original View to copy the View's JSDoc description.
|
||||
> 3. **`satisfies Meta`** — the meta object must use `satisfies Meta<...>` (not `as Meta<...>` or `: Meta<...> =`). Type assertions and annotations erase the inferred component type that docgen relies on.
|
||||
|
||||
#### Linking Figma Designs
|
||||
|
||||
This package uses [@storybook/addon-designs](https://github.com/storybookjs/addon-designs) to embed Figma designs directly in Storybook. This helps developers compare their implementation with the design specs.
|
||||
|
||||
1. **Get the Figma URL**: Open your design in Figma, click "Share" → "Copy link"
|
||||
2. **Add to story parameters**: Include the `design` object in the meta's `parameters`
|
||||
3. **Supported URL formats**:
|
||||
- File links: `https://www.figma.com/file/...`
|
||||
- Design links: `https://www.figma.com/design/...`
|
||||
- Specific node: `https://www.figma.com/design/...?node-id=123-456`
|
||||
|
||||
Example with Figma integration:
|
||||
|
||||
```tsx
|
||||
const meta = {
|
||||
title: "RoomList/RoomListSearchView",
|
||||
component: RoomListSearchViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
// ... your args
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=98-1979",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RoomListSearchViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
```
|
||||
|
||||
The Figma design will appear in the "Design" tab in Storybook.
|
||||
|
||||
#### Non-UI Utility Stories
|
||||
|
||||
For utility functions, helpers, and other non-UI exports, create documentation stories using TSX format with TypeDoc-generated markdown.
|
||||
|
||||
`src/core/utils/humanize.stories.tsx`
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { Markdown } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import humanizeTimeDoc from "../../typedoc/functions/humanizeTime.md?raw";
|
||||
|
||||
const meta = {
|
||||
title: "Core/Humanize",
|
||||
parameters: {
|
||||
docs: {
|
||||
page: () => (
|
||||
<>
|
||||
<h1>humanize</h1>
|
||||
<Markdown>{humanizeTimeDoc}</Markdown>
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
tags: ["autodocs", "skip-test"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
// Docs-only story - renders nothing but triggers autodocs
|
||||
export const Docs = {
|
||||
render: () => null,
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Be sure to include the `skip-test` tag in your utility stories to prevent them from running as visual tests.
|
||||
|
||||
**Workflow:**
|
||||
|
||||
1. Write TsDoc in your utility function
|
||||
2. Export the function from `src/index.ts`
|
||||
3. Run `pnpm build:doc` to generate TypeDoc markdown
|
||||
4. Create a `.stories.tsx` file importing the generated markdown
|
||||
5. The documentation appears automatically in Storybook
|
||||
|
||||
### Tests
|
||||
|
||||
Two types of tests are available: unit tests and visual regression tests.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
These tests cover the logic of the components and utilities. Built with Vitest
|
||||
and React Testing Library.
|
||||
|
||||
```bash
|
||||
pnpm test:unit
|
||||
```
|
||||
|
||||
### Visual Regression Tests
|
||||
|
||||
These tests ensure the UI components render correctly.
|
||||
Built with Storybook and run under vitest using playwright.
|
||||
|
||||
```bash
|
||||
pnpm test:storybook:update
|
||||
```
|
||||
|
||||
Each story will be rendered and a screenshot will be taken and compared to the
|
||||
existing baseline. If there are visual changes or AXE violation, the test will
|
||||
fail.
|
||||
|
||||
Screenshots are located in `packages/shared-components/__vis__/`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> In case of docker issues with Playwright, see [playwright EW documentation](https://github.com/element-hq/element-web/blob/develop/docs/playwright.md#supported-container-runtimes).
|
||||
|
||||
### Translations
|
||||
|
||||
First see our [translation guide](../../docs/translating.md) and [translation dev guide](../../docs/translating-dev.md).
|
||||
To generate translation strings for this package, run:
|
||||
|
||||
```bash
|
||||
pnpm i18n
|
||||
```
|
||||
|
||||
## Publish a new version
|
||||
|
||||
Two steps are required to publish a new version of this package:
|
||||
|
||||
1. Bump the version in `package.json` following semver rules and open a PR.
|
||||
2. Once merged run the [github workflow](https://github.com/element-hq/element-web/actions/workflows/npm-publish.yaml)
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |