Move shared components to a packages/ directory (#30962)

* Move shared components to a packages/ directory

so they can be publish more sensibly

* Iterate towards split out shared-components module

 * Move shared component source into src/ subdir
 * Fix up imports
 * Include shared components in babel-ing (again)

* Remove now unused dependencies

* Update import in storybook preview

* ...except of course they aren't unused

if we import the shared components by source

* Ignore shared components deps

* Add shared-components to i18n paths

and upgrade web-i18n to version that supports doing so

* Move storybook stuff to shared-components

* Seems we don't need this anymore...

* Remove unused deps

and remove storybook plugin from eslint

* Presumably working-directory is only valid on run steps

* Ignore dep & run prettier

* Prettier on knips.ts

* Hopefully run in right dir

* Remember how to software write

* Okay... how about THIS way?

* Oh right, they were git ignored. Sigh.

* Add concurrently

* Ignore in knip

* Better?

* Paaaaaaaackageeeeeeees

* More packages

* Move playwright snapshots

* Still need a custom snapshots dir

* Add eslint back

* Oh, now knip sees them

* Fix another import

* Don't lint shared-components with everything else

Okay, eslint & tsconfig are tied too closely for this to work and
running tsc on the shared components will need its deps installing

* Maybe lint shared components

please?

* Not quite

* Remove storybook again

Re-check if it does work without it

* Remove storybook eslint plugin

as we're not linting storybook here anymore

* Remove this too

* We do need it here though
This commit is contained in:
David Baker
2025-10-13 11:54:50 +01:00
committed by GitHub
parent c96da5dbf8
commit 2698ad422e
177 changed files with 6179 additions and 2562 deletions

View File

@@ -0,0 +1,63 @@
module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: {
project: ["./tsconfig.json"],
},
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",
},
overrides: [
{
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
rules: {
"@typescript-eslint/explicit-function-return-type": [
"error",
{
allowExpressions: true,
},
],
// Remove Babel things manually due to override limitations
"@babel/no-invalid-this": ["off"],
// We're okay being explicit at the moment
"@typescript-eslint/no-empty-interface": "off",
// We disable this while we're transitioning
"@typescript-eslint/no-explicit-any": "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",
},
],
},
},
],
settings: {
react: {
version: "detect",
},
},
};

View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,28 @@
/*
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: "Element Web",
brandUrl: "https://github.com/element-hq/element-web",
brandImage: "https://element.io/images/logo-ele-secondary.svg",
brandTarget: "_self",
});

View File

@@ -0,0 +1,61 @@
/*
* 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";
// We can't import `shared/i18n.tsx` directly here.
// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
import json from "../../../webapp/i18n/languages.json";
const languages = Object.keys(json).filter((lang) => lang !== "default");
/**
* Returns the title of a language in the user's locale.
*/
function languageTitle(language: string): string {
return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || 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>
);
},
};

View File

@@ -0,0 +1,46 @@
/*
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 path from "node:path";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../../../webapp"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs", "@storybook/addon-a11y"],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,
},
typescript: {
reactDocgen: "react-docgen-typescript",
},
async viteFinal(config) {
return mergeConfig(config, {
resolve: {
alias: {
// Alias used by i18n.tsx
$webapp: path.resolve("../../webapp"),
},
},
// Needed for counterpart to work
plugins: [nodePolyfills({ include: ["process", "util"] })],
server: {
allowedHosts: ["localhost", ".docker.internal"],
},
});
},
refs: {
"compound-web": {
title: "Compound Web",
url: "https://element-hq.github.io/compound-web/",
},
},
};
export default config;

View File

@@ -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));

View File

@@ -0,0 +1,10 @@
/*
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);
}

View File

@@ -0,0 +1,93 @@
import type { ArgTypes, Preview, Decorator, ReactRenderer, StrictArgs } from "@storybook/react-vite";
import "../../../res/css/shared.pcss";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/utils/i18n";
import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf";
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",
},
initialGlobals: {
theme: "system",
language: "en",
},
} 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 preview: Preview = {
tags: ["autodocs"],
decorators: [withThemeProvider, withTooltipProvider],
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],
};
export default preview;

View File

@@ -0,0 +1,37 @@
/*
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 { waitForPageReady } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const customSnapshotsDir = `${process.cwd()}/playwright/snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/received/`;
/**
* @type {import('@storybook/test-runner').TestRunnerConfig}
*/
const config = {
setup(page) {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
await waitForPageReady(page);
// If you want to take screenshot of multiple browsers, use
// page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: `${context.id}-${process.platform}`,
storeReceivedOnFailure: true,
customReceivedDir,
customDiffDir: customReceivedDir,
});
},
};
export default config;

View File

@@ -0,0 +1,52 @@
{
"name": "element-web-shared-components",
"version": "1.12.1",
"description": "Shared components for Element",
"author": "New Vector Ltd.",
"repository": {
"type": "git",
"url": "https://github.com/element-hq/element-web"
},
"license": "SEE LICENSE IN README.md",
"files": [
"lib",
"src",
"LICENSE",
"README.md",
"package.json"
],
"scripts": {
"postinstall": "patch-package",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src && prettier --check .",
"lint:types": "tsc --noEmit --jsx react",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"",
"test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot"
},
"dependencies": {},
"devDependencies": {
"@storybook/addon-a11y": "^9.1.10",
"@storybook/addon-designs": "^10.0.2",
"@storybook/addon-docs": "^9.1.10",
"@storybook/icons": "^1.6.0",
"@storybook/react-vite": "^9.1.10",
"@storybook/test-runner": "^0.23.0",
"concurrently": "^9.2.1",
"eslint": "8",
"eslint-plugin-storybook": "^9.1.10",
"jest-image-snapshot": "^6.5.1",
"patch-package": "^8.0.1",
"prettier": "^3.6.2",
"storybook": "^9.1.10",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-plugin-node-polyfills": "^0.24.0"
},
"engines": {
"node": ">=20.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -0,0 +1,46 @@
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
index 498bb69..4e89216 100644
--- a/node_modules/@types/mdx/types.d.ts
+++ b/node_modules/@types/mdx/types.d.ts
@@ -5,7 +5,7 @@
*/
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
// defined or not.
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
/**
* This matches any function component types that ar part of `ElementType`.
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
/**
* A valid JSX string component.
*/
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
/**
* A JSX element returned by MDX content.
*/
-export type Element = JSX.Element;
+export type Element = React.JSX.Element;
/**
* A valid JSX function component.
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
*/
type ClassComponent<Props> = ElementType extends never
// If JSX.ElementType isnt defined, the valid return type is a constructor that returns JSX.ElementClass
- ? new(props: Props) => JSX.ElementClass
+ ? new(props: Props) => React.JSX.ElementClass
: ClassElementType extends never
// If JSX.ElementType is defined, but doesnt allow constructors, function components are disallowed.
? never
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
export type MDXComponents =
& NestedMDXComponents
& {
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
}
& {
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,8 @@
/*
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.
*/
declare module "*.css";

View File

@@ -0,0 +1,23 @@
/*
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 ViewModel } from "./ViewModel";
/**
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
*/
export class MockViewModel<T> implements ViewModel<T> {
public constructor(private snapshot: T) {}
public getSnapshot = (): T => {
return this.snapshot;
};
public subscribe(listener: () => void): () => void {
return () => undefined;
}
}

View File

@@ -0,0 +1,23 @@
/*
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.
*/
/**
* The interface for a generic View Model passed to the shared components.
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
*/
export interface ViewModel<T> {
/**
* The current snapshot of the view model.
*/
getSnapshot: () => T;
/**
* Subscribes to changes in the view model.
* The listener will be called whenever the snapshot changes.
*/
subscribe: (listener: () => void) => () => void;
}

View File

@@ -0,0 +1,52 @@
/*
* 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, { type JSX, useMemo, type ComponentType } from "react";
import { omitBy, pickBy } from "lodash";
import { MockViewModel } from "./MockViewModel";
import { type ViewModel } from "./ViewModel";
interface ViewWrapperProps<V> {
/**
* The component to render, which should accept a `vm` prop of type `V`.
*/
Component: ComponentType<{ vm: V }>;
/**
* The props to pass to the component, which can include both snapshot data and actions.
*/
props: Record<string, any>;
}
/**
* A wrapper component that creates a view model instance and passes it to the specified component.
* This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories.
*
* Props is parsed and split into snapshot and actions. Where values that are functions (`typeof Function`) are considered actions and the rest is considered the snapshot.
*
* @example
* ```tsx
* <ViewWrapper<SnapshotType, ViewModelType> props={Snapshot&Actions} Component={MyComponent} />
* ```
*/
export function ViewWrapper<T, V extends ViewModel<T>>({
props,
Component,
}: Readonly<ViewWrapperProps<V>>): JSX.Element {
const vm = useMemo(() => {
const isFunction = (value: any): value is typeof Function => typeof value === typeof Function;
const snapshot = omitBy(props, isFunction) as T;
const actions = pickBy(props, isFunction);
const vm = new MockViewModel<T>(snapshot);
Object.assign(vm, actions);
return vm as unknown as V;
}, [props]);
return <Component vm={vm} />;
}

View File

@@ -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.
*/
.audioPlayer {
padding: var(--cpd-space-4x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x);
}
.mediaInfo {
/* Makes the ellipsis on the file name work */
overflow: hidden;
}
.mediaName {
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.byline {
font: var(--cpd-font-body-xs-regular);
}
.clock {
white-space: nowrap;
}
.error {
color: var(--cpd-color-text-critical-primary);
}

View File

@@ -0,0 +1,66 @@
/*
* 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, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
AudioPlayerView,
type AudioPlayerViewActions,
type AudioPlayerViewSnapshot,
type AudioPlayerViewModel,
} from "./AudioPlayerView";
import { ViewWrapper } from "../../ViewWrapper";
type AudioPlayerProps = AudioPlayerViewSnapshot & AudioPlayerViewActions;
const AudioPlayerViewWrapper = (props: AudioPlayerProps): JSX.Element => (
<ViewWrapper<AudioPlayerViewSnapshot, AudioPlayerViewModel> Component={AudioPlayerView} props={props} />
);
export default {
title: "Audio/AudioPlayerView",
component: AudioPlayerViewWrapper,
tags: ["autodocs"],
argTypes: {
playbackState: {
options: ["stopped", "playing", "paused", "decoding"],
control: { type: "select" },
},
},
args: {
mediaName: "Sample Audio",
durationSeconds: 300,
playedSeconds: 120,
percentComplete: 30,
playbackState: "stopped",
sizeBytes: 3500,
error: false,
togglePlay: fn(),
onKeyDown: fn(),
onSeekbarChange: fn(),
},
} as Meta<typeof AudioPlayerViewWrapper>;
const Template: StoryFn<typeof AudioPlayerViewWrapper> = (args) => <AudioPlayerViewWrapper {...args} />;
export const Default = Template.bind({});
export const NoMediaName = Template.bind({});
NoMediaName.args = {
mediaName: undefined,
};
export const NoSize = Template.bind({});
NoSize.args = {
sizeBytes: undefined,
};
export const HasError = Template.bind({});
HasError.args = {
error: true,
};

View File

@@ -0,0 +1,78 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import userEvent from "@testing-library/user-event";
import { fireEvent } from "@testing-library/dom";
import * as stories from "./AudioPlayerView.stories.tsx";
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
import { MockViewModel } from "../../MockViewModel";
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
describe("AudioPlayerView", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("renders the audio player in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the audio player without media name", () => {
const { container } = render(<NoMediaName />);
expect(container).toMatchSnapshot();
});
it("renders the audio player without size", () => {
const { container } = render(<NoSize />);
expect(container).toMatchSnapshot();
});
it("renders the audio player in error state", () => {
const { container } = render(<HasError />);
expect(container).toMatchSnapshot();
});
const onKeyDown = jest.fn();
const togglePlay = jest.fn();
const onSeekbarChange = jest.fn();
class AudioPlayerViewModel extends MockViewModel<AudioPlayerViewSnapshot> implements AudioPlayerViewActions {
public onKeyDown = onKeyDown;
public togglePlay = togglePlay;
public onSeekbarChange = onSeekbarChange;
}
it("should attach vm methods", async () => {
const user = userEvent.setup();
const vm = new AudioPlayerViewModel({
playbackState: "stopped",
mediaName: "Test Audio",
durationSeconds: 300,
playedSeconds: 120,
percentComplete: 30,
sizeBytes: 3500,
error: false,
});
render(<AudioPlayerView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Play" }));
expect(togglePlay).toHaveBeenCalled();
// user event doesn't support change events on sliders, so we use fireEvent
fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } });
expect(onSeekbarChange).toHaveBeenCalled();
await user.type(screen.getByLabelText("Audio player"), "{arrowup}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" }));
});
});

View File

@@ -0,0 +1,143 @@
/*
* 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, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
import { type ViewModel } from "../../ViewModel";
import { useViewModel } from "../../useViewModel";
import { MediaBody } from "../../message-body/MediaBody";
import { Flex } from "../../utils/Flex";
import styles from "./AudioPlayerView.module.css";
import { PlayPauseButton } from "../PlayPauseButton";
import { type PlaybackState } from "../playback";
import { _t } from "../../utils/i18n";
import { formatBytes } from "../../utils/FormattingUtils";
import { Clock } from "../Clock";
import { SeekBar } from "../SeekBar";
export interface AudioPlayerViewSnapshot {
/**
* The playback state of the audio player.
*/
playbackState: PlaybackState;
/**
* Name of the media being played.
* @default Fallback to "timeline|m.audio|unnamed_audio" string if not provided.
*/
mediaName?: string;
/**
* Size of the audio file in bytes.
* Hided if not provided.
*/
sizeBytes?: number;
/**
* The duration of the audio clip in seconds.
*/
durationSeconds: number;
/**
* The percentage of the audio that has been played.
* Ranges from 0 to 100.
*/
percentComplete: number;
/**
* The number of seconds that have been played.
*/
playedSeconds: number;
/**
* Indicates if there was an error downloading the audio.
*/
error: boolean;
}
export interface AudioPlayerViewActions {
/**
* Handles key down events for the audio player.
*/
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
/**
* Toggles the play/pause state of the audio player.
*/
togglePlay: MouseEventHandler<HTMLButtonElement>;
/**
* Handles changes to the seek bar.
*/
onSeekbarChange: ChangeEventHandler<HTMLInputElement>;
}
/**
* The view model for the audio player.
*/
export type AudioPlayerViewModel = ViewModel<AudioPlayerViewSnapshot> & AudioPlayerViewActions;
interface AudioPlayerViewProps {
/**
* The view model for the audio player.
*/
vm: AudioPlayerViewModel;
}
/**
* AudioPlayer component displays an audio player with play/pause controls, seek bar, and media information.
* The component expects a view model that provides the current state of the audio playback,
*
* @example
* ```tsx
* <AudioPlayerView vm={audioPlayerViewModel} />
* ```
*/
export function AudioPlayerView({ vm }: Readonly<AudioPlayerViewProps>): JSX.Element {
const {
playbackState,
mediaName = _t("timeline|m.audio|unnamed_audio"),
sizeBytes,
durationSeconds,
playedSeconds,
percentComplete,
error,
} = useViewModel(vm);
const fileSize = sizeBytes ? `(${formatBytes(sizeBytes)})` : null;
const disabled = playbackState === "decoding";
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return (
<>
<MediaBody
className={styles.audioPlayer}
tabIndex={0}
onKeyDown={vm.onKeyDown}
aria-label={_t("timeline|m.audio|audio_player")}
role="region"
>
<Flex gap="var(--cpd-space-2x)" align="center">
<PlayPauseButton
// Prevent tabbing into the button
// Keyboard navigation is handled at the MediaBody level
tabIndex={-1}
disabled={disabled}
playing={playbackState === "playing"}
togglePlay={vm.togglePlay}
/>
<Flex direction="column" className={styles.mediaInfo}>
<span className={styles.mediaName} data-testid="audio-player-name">
{mediaName}
</span>
<Flex className={styles.byline} gap="var(--cpd-space-1-5x)">
<Clock seconds={durationSeconds} />
{fileSize}
</Flex>
</Flex>
</Flex>
<Flex align="center" gap="var(--cpd-space-1x)" data-testid="audio-player-seek">
<SeekBar tabIndex={-1} disabled={disabled} value={percentComplete} onChange={vm.onSeekbarChange} />
<Clock className={styles.clock} seconds={playedSeconds} role="timer" />
</Flex>
</MediaBody>
{error && <span className={styles.error}>{_t("timeline|m.audio|error_downloading_audio")}</span>}
</>
);
}

View File

@@ -0,0 +1,369 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AudioPlayerView renders the audio player in default state 1`] = `
<div>
<div
aria-label="Audio player"
class="mx_MediaBody mediaBody audioPlayer"
role="region"
tabindex="0"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-label="Play"
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="-1"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</div>
</button>
<div
class="flex mediaInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mediaName"
data-testid="audio-player-name"
>
Sample Audio
</span>
<div
class="flex byline"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<time
class="mx_Clock"
datetime="PT5M"
>
05:00
</time>
(3.42 KB)
</div>
</div>
</div>
<div
class="flex"
data-testid="audio-player-seek"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="Audio seek bar"
class="seekBar"
max="100"
min="0"
step="1"
style="--fillTo: 0.3;"
tabindex="-1"
type="range"
value="30"
/>
<time
class="mx_Clock clock"
datetime="PT2M"
role="timer"
>
02:00
</time>
</div>
</div>
</div>
`;
exports[`AudioPlayerView renders the audio player in error state 1`] = `
<div>
<div
aria-label="Audio player"
class="mx_MediaBody mediaBody audioPlayer"
role="region"
tabindex="0"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-label="Play"
aria-labelledby="«ri»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="-1"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</div>
</button>
<div
class="flex mediaInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mediaName"
data-testid="audio-player-name"
>
Sample Audio
</span>
<div
class="flex byline"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<time
class="mx_Clock"
datetime="PT5M"
>
05:00
</time>
(3.42 KB)
</div>
</div>
</div>
<div
class="flex"
data-testid="audio-player-seek"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="Audio seek bar"
class="seekBar"
max="100"
min="0"
step="1"
style="--fillTo: 0.3;"
tabindex="-1"
type="range"
value="30"
/>
<time
class="mx_Clock clock"
datetime="PT2M"
role="timer"
>
02:00
</time>
</div>
</div>
<span
class="error"
>
Error downloading audio
</span>
</div>
`;
exports[`AudioPlayerView renders the audio player without media name 1`] = `
<div>
<div
aria-label="Audio player"
class="mx_MediaBody mediaBody audioPlayer"
role="region"
tabindex="0"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-label="Play"
aria-labelledby="«r6»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="-1"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</div>
</button>
<div
class="flex mediaInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mediaName"
data-testid="audio-player-name"
>
Unnamed audio
</span>
<div
class="flex byline"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<time
class="mx_Clock"
datetime="PT5M"
>
05:00
</time>
(3.42 KB)
</div>
</div>
</div>
<div
class="flex"
data-testid="audio-player-seek"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="Audio seek bar"
class="seekBar"
max="100"
min="0"
step="1"
style="--fillTo: 0.3;"
tabindex="-1"
type="range"
value="30"
/>
<time
class="mx_Clock clock"
datetime="PT2M"
role="timer"
>
02:00
</time>
</div>
</div>
</div>
`;
exports[`AudioPlayerView renders the audio player without size 1`] = `
<div>
<div
aria-label="Audio player"
class="mx_MediaBody mediaBody audioPlayer"
role="region"
tabindex="0"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-label="Play"
aria-labelledby="«rc»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="-1"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</div>
</button>
<div
class="flex mediaInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mediaName"
data-testid="audio-player-name"
>
Sample Audio
</span>
<div
class="flex byline"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<time
class="mx_Clock"
datetime="PT5M"
>
05:00
</time>
</div>
</div>
</div>
<div
class="flex"
data-testid="audio-player-seek"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="Audio seek bar"
class="seekBar"
max="100"
min="0"
step="1"
style="--fillTo: 0.3;"
tabindex="-1"
type="range"
value="30"
/>
<time
class="mx_Clock clock"
datetime="PT2M"
role="timer"
>
02:00
</time>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,9 @@
/*
* 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.
*/
export type { AudioPlayerViewModel, AudioPlayerViewSnapshot } from "./AudioPlayerView";
export { AudioPlayerView } from "./AudioPlayerView";

View File

@@ -0,0 +1,29 @@
/*
* 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 type { Meta, StoryFn } from "@storybook/react-vite";
import { Clock } from "./Clock";
export default {
title: "Audio/Clock",
component: Clock,
tags: ["autodocs"],
args: {
seconds: 20,
},
} as Meta<typeof Clock>;
const Template: StoryFn<typeof Clock> = (args) => <Clock {...args} />;
export const Default = Template.bind({});
export const LotOfSeconds = Template.bind({});
LotOfSeconds.args = {
seconds: 99999999999999,
};

View File

@@ -0,0 +1,26 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./Clock.stories.tsx";
const { Default, LotOfSeconds } = composeStories(stories);
describe("Clock", () => {
it("renders the clock", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the clock with a lot of seconds", () => {
const { container } = render(<LotOfSeconds />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
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, { type HTMLProps } from "react";
import { Temporal } from "temporal-polyfill";
import classNames from "classnames";
import { formatSeconds } from "../../utils/DateUtils";
export interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role" | "className"> {
seconds: number;
}
/**
* Clock which represents time periods rather than absolute time.
* Simply converts seconds using formatSeconds().
* Note that in this case hours will not be displayed, making it possible to see "82:29".
*/
export class Clock extends React.Component<Props> {
public shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
private calculateDuration(seconds: number): string | undefined {
if (isNaN(seconds)) return undefined;
return new Temporal.Duration(0, 0, 0, 0, 0, 0, Math.round(seconds))
.round({ smallestUnit: "seconds", largestUnit: "hours" })
.toString();
}
public render(): React.ReactNode {
const { seconds, role } = this.props;
return (
<time
dateTime={this.calculateDuration(seconds)}
aria-live={this.props["aria-live"]}
role={role}
/* Keep class for backward compatibility with parent component */
className={classNames("mx_Clock", this.props.className)}
>
{formatSeconds(seconds)}
</time>
);
}
}

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Clock renders the clock 1`] = `
<div>
<time
class="mx_Clock"
datetime="PT20S"
>
00:20
</time>
</div>
`;
exports[`Clock renders the clock with a lot of seconds 1`] = `
<div>
<time
class="mx_Clock"
datetime="PT27777777777H46M39S"
>
27777777777:46:39
</time>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { Clock } from "./Clock";

View File

@@ -0,0 +1,11 @@
/*
* 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.
*/
.button {
border-radius: 32px;
background-color: var(--cpd-color-bg-subtle-primary);
}

View File

@@ -0,0 +1,26 @@
/*
* 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 { fn } from "storybook/test";
import { PlayPauseButton } from "./PlayPauseButton";
import type { Meta, StoryObj } from "@storybook/react-vite";
const meta = {
title: "Audio/PlayPauseButton",
component: PlayPauseButton,
tags: ["autodocs"],
args: {
togglePlay: fn(),
},
} satisfies Meta<typeof PlayPauseButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Playing: Story = { args: { playing: true } };

View File

@@ -0,0 +1,37 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { fn } from "storybook/test";
import * as stories from "./PlayPauseButton.stories.tsx";
const { Default, Playing } = composeStories(stories);
describe("PlayPauseButton", () => {
it("renders the button in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the button in playing state", () => {
const { container } = render(<Playing />);
expect(container).toMatchSnapshot();
});
it("calls togglePlay when clicked", async () => {
const user = userEvent.setup();
const togglePlay = fn();
const { getByRole } = render(<Default togglePlay={togglePlay} />);
await user.click(getByRole("button"));
expect(togglePlay).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
/*
* 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, { type HTMLAttributes, type JSX, type MouseEventHandler } from "react";
import { IconButton } from "@vector-im/compound-web";
import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid";
import styles from "./PlayPauseButton.module.css";
import { _t } from "../../utils/i18n";
export interface PlayPauseButtonProps extends HTMLAttributes<HTMLButtonElement> {
/**
* Whether the button is disabled.
* @default false
*/
disabled?: boolean;
/**
* Whether the audio is currently playing.
* @default false
*/
playing?: boolean;
/**
* Function to toggle play/pause state.
*/
togglePlay: MouseEventHandler<HTMLButtonElement>;
}
/**
* A button component that toggles between play and pause states for audio playback.
*
* @example
* ```tsx
* <PlayPauseButton playing={true} togglePlay={() => {}} />
* ```
*/
export function PlayPauseButton({
disabled = false,
playing = false,
togglePlay,
...rest
}: Readonly<PlayPauseButtonProps>): JSX.Element {
const label = playing ? _t("action|pause") : _t("action|play");
return (
<IconButton
size="32px"
aria-label={label}
tooltip={label}
onClick={togglePlay}
className={styles.button}
disabled={disabled}
{...rest}
>
{playing ? <Pause /> : <Play />}
</IconButton>
);
}

View File

@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlayPauseButton renders the button in default state 1`] = `
<div>
<button
aria-disabled="false"
aria-label="Play"
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</div>
</button>
</div>
`;
exports[`PlayPauseButton renders the button in playing state 1`] = `
<div>
<button
aria-disabled="false"
aria-label="Pause"
aria-labelledby="«r6»"
class="_icon-button_1pz9o_8 button"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 4a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2"
/>
</svg>
</div>
</button>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { PlayPauseButton } from "./PlayPauseButton";

View File

@@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
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.
*/
/* CSS inspiration from: */
/* * https://www.w3schools.com/howto/howto_js_rangeslider.asp */
/* * https://stackoverflow.com/a/28283806 */
/* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
.seekBar {
/* default, overridden in JS */
--fillTo: 1;
/* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */
/* need to support IE. */
appearance: none; /* default style override */
width: 100%;
height: 1px;
background: var(--cpd-color-gray-600);
outline: none; /* remove blue selection border */
position: relative; /* for before+after pseudo elements later on */
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none; /* default style override */
/* Dev note: This needs to be duplicated with the -moz-range-thumb selector */
/* because otherwise Edge (webkit) will fail to see the styles and just refuse */
/* to apply them. */
width: 8px;
height: 8px;
border-radius: 8px;
background-color: var(--cpd-color-gray-800);
cursor: pointer;
}
&::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 8px;
background-color: var(--cpd-color-gray-800);
cursor: pointer;
/* Firefox adds a border on the thumb */
border: none;
}
/* This is for webkit support, but we can't limit the functionality of it to just webkit */
/* browsers. Firefox responds to webkit-prefixed values now, which means we can't use media */
/* or support queries to selectively apply the rule. An upside is that this CSS doesn't work */
/* in firefox, so it's just wasted CPU/GPU time. */
&::before {
/* ::before to ensure it ends up under the thumb */
content: "";
background-color: var(--cpd-color-gray-800);
/* Absolute positioning to ensure it overlaps with the existing bar */
position: absolute;
top: 0;
left: 0;
/* Sizing to match the bar */
width: 100%;
height: 1px;
/* And finally dynamic width without overly hurting the rendering engine. */
transform-origin: 0 100%;
transform: scaleX(var(--fillTo));
}
/* This is firefox's built-in support for the above, with 100% less hacks. */
&::-moz-range-progress {
background-color: var(--cpd-color-gray-800);
height: 1px;
}
&:disabled {
opacity: 0.5;
}
/* Increase clickable area for the slider (approximately same size as browser default) */
/* We do it this way to keep the same padding and margins of the element, avoiding margin math. */
/* Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ */
&::after {
content: "";
position: absolute;
top: -6px;
bottom: -6px;
left: 0;
right: 0;
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 { useArgs } from "storybook/preview-api";
import { SeekBar } from "./SeekBar";
import type { Meta, StoryFn } from "@storybook/react-vite";
export default {
title: "Audio/SeekBar",
component: SeekBar,
tags: ["autodocs"],
argTypes: {
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
},
},
args: {
value: 50,
},
} as Meta<typeof SeekBar>;
const Template: StoryFn<typeof SeekBar> = (args) => {
const [, updateArgs] = useArgs();
return <SeekBar onChange={(evt) => updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />;
};
export const Default = Template.bind({});
export const Disabled = Template.bind({});
Disabled.args = {
disabled: true,
};

View File

@@ -0,0 +1,20 @@
/*
* 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 { render } from "jest-matrix-react";
import React from "react";
import { composeStories } from "@storybook/react-vite";
import * as stories from "./SeekBar.stories.tsx";
const { Default } = composeStories(stories);
describe("Seekbar", () => {
it("renders the clock", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,58 @@
/*
* 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, { type CSSProperties, type JSX, useEffect, useMemo, useState } from "react";
import { throttle } from "lodash";
import classNames from "classnames";
import style from "./SeekBar.module.css";
import { _t } from "../../utils/i18n";
export interface SeekBarProps extends React.InputHTMLAttributes<HTMLInputElement> {
/**
* The current value of the seek bar, between 0 and 100.
* @default 0
*/
value?: number;
}
interface ISeekCSS extends CSSProperties {
"--fillTo": number;
}
/**
* A seek bar component for audio playback.
*
* @example
* ```tsx
* <SeekBar value={50} onChange={(e) => console.log("New value", e.target.value)} />
* ```
*/
export function SeekBar({ value = 0, className, ...rest }: Readonly<SeekBarProps>): JSX.Element {
const [newValue, setNewValue] = useState(value);
// Throttle the value setting to avoid excessive re-renders
const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []);
useEffect(() => {
setThrottledValue(value);
}, [value, setThrottledValue]);
return (
<input
type="range"
className={classNames(style.seekBar, className)}
onMouseDown={(e) => e.stopPropagation()}
min={0}
max={100}
value={newValue}
step={1}
style={{ "--fillTo": newValue / 100 } as ISeekCSS}
aria-label={_t("a11y|seek_bar_label")}
{...rest}
/>
);
}

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Seekbar renders the clock 1`] = `
<div>
<input
aria-label="Audio seek bar"
class="seekBar"
max="100"
min="0"
step="1"
style="--fillTo: 0.5;"
type="range"
value="50"
/>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { SeekBar } from "./SeekBar";

View File

@@ -0,0 +1,16 @@
/*
* 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.
*/
/**
* Represents the possible states of playback.
* - "preparing": The audio is being prepared for playback (e.g., loading or buffering).
* - "decoding": The audio is being decoded and is not ready for playback.
* - "stopped": The playback has been stopped, with no progress on the timeline.
* - "paused": The playback is paused, with some progress on the timeline.
* - "playing": The playback is actively progressing through the timeline.
*/
export type PlaybackState = "decoding" | "stopped" | "paused" | "playing" | "preparing";

View File

@@ -0,0 +1,31 @@
/*
* 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.
*/
.avatarWithDetails {
display: flex;
align-items: center;
border-radius: 12px;
background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x);
gap: var(--cpd-space-2x);
.title {
display: inline-block;
font-weight: var(--cpd-font-weight-semibold);
font-size: var(--cpd-font-size-body-md);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
font-size: var(--cpd-font-size-body-sm);
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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 { type Meta, type StoryObj } from "@storybook/react-vite/*";
import { AvatarWithDetails } from "./AvatarWithDetails";
const meta = {
title: "Avatar/AvatarWithDetails",
component: AvatarWithDetails,
tags: ["autodocs"],
args: {
avatar: <div style={{ width: 40, height: 40, backgroundColor: "#888", borderRadius: "50%" }} />,
details: "Details about the avatar go here",
title: "Room Name",
},
} satisfies Meta<typeof AvatarWithDetails>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,21 @@
/*
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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./AvatarWithDetails.stories.tsx";
const { Default } = composeStories(stories);
describe("AvatarWithDetails", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,65 @@
/*
* 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 ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
import React from "react";
import classNames from "classnames";
import styles from "./AvatarWithDetails.module.css";
import { Flex } from "../../utils/Flex";
export type AvatarWithDetailsProps<C extends ElementType> = {
/**
* The HTML tag.
* @default "div"
*/
as?: C;
/**
* The CSS class name.
*/
className?: string;
/**
* The title/label next to the avatar. Usually the user or room name.
*/
title: string;
/**
* A label with details to display under the avatar title.
* Commonly used to display the number of participants in a room.
*/
details: React.ReactNode;
/** The avatar to display. */
avatar: React.ReactNode;
} & ComponentProps<C>;
/**
* A component to display an avatar with a title next to it in a grey box.
*
* @example
* ```tsx
* <AvatarWithDetails title="Room Name" details="10 participants" className="custom-class" />
* ```
*/
export function AvatarWithDetails<C extends React.ElementType = "div">({
as,
className,
details,
avatar,
title,
...props
}: PropsWithChildren<AvatarWithDetailsProps<C>>): JSX.Element {
const Component = as || "div";
return (
<Component className={classNames(styles.avatarWithDetails, className)} {...props}>
{avatar}
<Flex direction="column">
<span className={styles.title}>{title}</span>
<span className={styles.details}>{details}</span>
</Flex>
</Component>
);
}

View File

@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AvatarWithDetails renders a textual event 1`] = `
<div>
<div
class="avatarWithDetails"
>
<div
style="width: 40px; height: 40px; background-color: rgb(136, 136, 136); border-radius: 50%;"
/>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="title"
>
Room Name
</span>
<span
class="details"
>
Details about the avatar go here
</span>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { AvatarWithDetails } from "./AvatarWithDetails";

View File

@@ -0,0 +1,25 @@
/*
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 React from "react";
import { type Meta, type StoryFn } from "@storybook/react-vite";
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
import { MockViewModel } from "../../MockViewModel";
export default {
title: "Event/TextualEvent",
component: TextualEventComponent,
tags: ["autodocs"],
args: {
vm: new MockViewModel({ content: "Dummy textual event text" }),
},
} as Meta<typeof TextualEventComponent>;
const Template: StoryFn<typeof TextualEventComponent> = (args) => <TextualEventComponent {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,21 @@
/*
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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./TextualEventView.stories.tsx";
const { Default } = composeStories(stories);
describe("TextualEventView", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,24 @@
/*
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, { type ReactNode, type JSX } from "react";
import { type ViewModel } from "../../ViewModel";
import { useViewModel } from "../../useViewModel";
export type TextualEventViewSnapshot = {
content: string | ReactNode;
};
export interface Props {
vm: ViewModel<TextualEventViewSnapshot>;
}
export function TextualEventView({ vm }: Props): JSX.Element {
const snapshot = useViewModel(vm);
return <div className="mx_TextualEvent">{snapshot.content}</div>;
}

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextualEventView renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"
>
Dummy textual event text
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
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.
*/
export { TextualEventView } from "./TextualEventView";

View File

@@ -0,0 +1,155 @@
/*
* 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 KeyboardEvent } from "react";
import { renderHook } from "jest-matrix-react";
import { useListKeyboardNavigation } from "./useListKeyboardNavigation";
describe("useListKeyDown", () => {
let mockList: HTMLUListElement;
let mockItems: HTMLElement[];
let mockEvent: Partial<KeyboardEvent<HTMLUListElement>>;
beforeEach(() => {
// Create mock DOM elements
mockList = document.createElement("ul");
mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")];
// Set up the DOM structure
mockItems.forEach((item, index) => {
item.setAttribute("tabindex", "0");
item.setAttribute("data-testid", `item-${index}`);
mockList.appendChild(item);
});
document.body.appendChild(mockList);
// Mock event object
mockEvent = {
preventDefault: jest.fn(),
key: "",
};
// Mock focus methods
mockItems.forEach((item) => {
item.focus = jest.fn();
item.click = jest.fn();
});
});
afterEach(() => {
document.body.removeChild(mockList);
jest.clearAllMocks();
});
function render(): {
current: {
listRef: React.RefObject<HTMLUListElement | null>;
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
onFocus: React.FocusEventHandler<HTMLUListElement>;
};
} {
const { result } = renderHook(() => useListKeyboardNavigation());
result.current.listRef.current = mockList;
return result;
}
it.each([
["Enter", "Enter"],
["Space", " "],
])("should handle %s key to click active element", (name, key) => {
const result = render();
// Mock document.activeElement
Object.defineProperty(document, "activeElement", {
value: mockItems[1],
configurable: true,
});
// Simulate key press
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[1].click).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each(
// key, finalPosition, startPosition
[
["ArrowDown", 1, 0],
["ArrowUp", 1, 2],
["Home", 0, 1],
["End", 2, 1],
],
)("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => {
const result = render();
mockList.contains = jest.fn().mockReturnValue(true);
Object.defineProperty(document, "activeElement", {
value: mockItems[startPosition],
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1);
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => {
const result = render();
mockList.contains = jest.fn().mockReturnValue(false);
const outsideElement = document.createElement("button");
Object.defineProperty(document, "activeElement", {
value: outsideElement,
configurable: true,
});
result.current.onKeyDown({
...mockEvent,
key,
} as KeyboardEvent<HTMLUListElement>);
// No item should be focused
mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled());
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
it("should not prevent default for unhandled keys", () => {
const result = render();
result.current.onKeyDown({
...mockEvent,
key: "Tab",
} as KeyboardEvent<HTMLUListElement>);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it("should focus the first item if list itself is focused", () => {
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[0].focus).toHaveBeenCalledTimes(1);
});
it("should focus the selected item if list itself is focused", () => {
mockItems[1].setAttribute("aria-selected", "true");
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[1].focus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,92 @@
/*
* 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 {
useCallback,
useRef,
type RefObject,
type KeyboardEvent,
type KeyboardEventHandler,
type FocusEventHandler,
type FocusEvent,
} from "react";
/**
* A hook that provides keyboard navigation for a list of options.
*/
export function useListKeyboardNavigation(): {
listRef: RefObject<HTMLUListElement | null>;
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
onFocus: FocusEventHandler<HTMLUListElement>;
} {
const listRef = useRef<HTMLUListElement>(null);
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
if (!listRef.current) return;
if (evt.target === listRef.current) {
// By default, focus the selected item
let selectedChild = listRef.current?.firstElementChild;
// If there is a selected item, focus that instead
for (const child of listRef.current.children) {
if (child.getAttribute("aria-selected") === "true") {
selectedChild = child;
break;
}
}
(selectedChild as HTMLElement)?.focus();
}
}, []);
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
const { key } = evt;
let handled = false;
switch (key) {
case "Enter":
case " ": {
handled = true;
(document.activeElement as HTMLElement).click();
break;
}
case "ArrowDown": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.nextElementSibling as HTMLElement)?.focus();
}
break;
}
case "ArrowUp": {
handled = true;
const currentFocus = document.activeElement;
if (listRef.current?.contains(currentFocus) && currentFocus) {
(currentFocus.previousElementSibling as HTMLElement)?.focus();
}
break;
}
case "Home": {
handled = true;
(listRef.current?.firstElementChild as HTMLElement)?.focus();
break;
}
case "End": {
handled = true;
(listRef.current?.lastElementChild as HTMLElement)?.focus();
break;
}
}
if (handled) {
evt.preventDefault();
}
}, []);
return { listRef, onKeyDown, onFocus };
}

View File

@@ -0,0 +1,17 @@
/*
* 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.
*/
.mediaBody {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: var(--cpd-space-2x);
max-width: 243px; /* use max-width instead of width so it fits within right panels */
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
padding: var(--cpd-space-1-5x) var(--cpd-space-3x);
}

View File

@@ -0,0 +1,24 @@
/*
* 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 { MediaBody } from "./MediaBody";
import type { Meta, StoryFn } from "@storybook/react-vite";
export default {
title: "MessageBody/MediaBody",
component: MediaBody,
tags: ["autodocs"],
args: {
children: "Media content goes here",
},
} as Meta<typeof MediaBody>;
const Template: StoryFn<typeof MediaBody> = (args) => <MediaBody {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,21 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./MediaBody.stories";
const { Default } = composeStories(stories);
describe("MediaBody", () => {
it("renders the media body", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,48 @@
/*
* 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 ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
import React from "react";
import classNames from "classnames";
import styles from "./MediaBody.module.css";
export type MediaBodyProps<C extends ElementType> = {
/**
* The HTML tag.
* @default "div"
*/
as?: C;
/**
* The CSS class name.
*/
className?: string;
} & ComponentProps<C>;
/**
* A component to display the body of a media message.
*
* @example
* ```tsx
* <MediaBody as="p" className="custom-class">Media body content</MediaBody>
* ```
*/
export function MediaBody<C extends React.ElementType = "div">({
as,
className,
children,
...props
}: PropsWithChildren<MediaBodyProps<C>>): JSX.Element {
const Component = as || "div";
// Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout
return (
<Component className={classNames("mx_MediaBody", styles.mediaBody, className)} {...props}>
{children}
</Component>
);
}

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MediaBody renders the media body 1`] = `
<div>
<div
class="mx_MediaBody mediaBody"
>
Media content goes here
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { MediaBody } from "./MediaBody";

View File

@@ -0,0 +1,17 @@
/*
* 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.
*/
.pill {
background-color: var(--cpd-color-bg-action-primary-rest);
padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x);
border-radius: 99px;
}
.label {
color: var(--cpd-color-text-on-solid-primary);
font: var(--cpd-font-body-sm-medium);
}

View File

@@ -0,0 +1,33 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Pill } from "./Pill";
const meta = {
title: "PillInput/Pill",
component: Pill,
tags: ["autodocs"],
args: {
label: "Pill",
children: <div style={{ width: 20, height: 20, borderRadius: "100%", backgroundColor: "#ccc" }} />,
onClick: fn(),
},
} satisfies Meta<typeof Pill>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithoutCloseButton: Story = {
args: {
onClick: undefined,
},
};

View File

@@ -0,0 +1,26 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./Pill.stories";
const { Default, WithoutCloseButton } = composeStories(stories);
describe("Pill", () => {
it("renders the pill", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the pill without close button", () => {
const { container } = render(<WithoutCloseButton />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,62 @@
/*
* 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, { type MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react";
import classNames from "classnames";
import { IconButton } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { Flex } from "../../utils/Flex";
import styles from "./Pill.module.css";
import { _t } from "../../utils/i18n";
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
/**
* The text label to display inside the pill.
*/
label: string;
/**
* Optional click handler for a close button.
* If provided, a close button will be rendered.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
/**
* A pill component that can display a label and an optional close button.
* The badge can also contain child elements, such as icons or avatars.
*
* @example
* ```tsx
* <Pill label="New" onClick={() => console.log("Closed")}>
* <SomeIcon />
* </Pill>
* ```
*/
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
const id = useId();
return (
<Flex
display="inline-flex"
gap="var(--cpd-space-1-5x)"
align="center"
className={classNames(styles.pill, className)}
{...props}
>
{children}
<span id={id} className={styles.label}>
{label}
</span>
{onClick && (
<IconButton aria-describedby={id} size="16px" onClick={onClick} aria-label={_t("action|delete")}>
<CloseIcon color="var(--cpd-color-icon-tertiary)" />
</IconButton>
)}
</Flex>
);
}

View File

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Pill renders the pill 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="«r0»"
>
Pill
</span>
<button
aria-describedby="«r0»"
aria-label="Delete"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 16px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`Pill renders the pill without close button 1`] = `
<div>
<div
class="flex pill"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
>
<div
style="width: 20px; height: 20px; border-radius: 100%; background-color: rgb(204, 204, 204);"
/>
<span
class="label"
id="«r1»"
>
Pill
</span>
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { Pill } from "./Pill";

View File

@@ -0,0 +1,34 @@
/*
* 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.
*/
.pillInput {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 20px;
padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x);
/* To match pill height in order to avoid the PillInput to grow when a pill is inserted */
min-height: 28px;
}
.pillInput:has(.input:focus) {
outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400);
}
.input {
all: unset;
width: 100%;
flex: 1;
color: var(--cpd-color-text-primary);
}
.input::placeholder {
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-md-regular);
}
.largerInput {
padding: var(--cpd-space-2x) 0;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PillInput } from "./PillInput";
const meta = {
title: "PillInput/PillInput",
component: PillInput,
tags: ["autodocs"],
args: {
children: (
<>
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
<div style={{ minWidth: 162, height: 28, backgroundColor: "#ccc", borderRadius: "99px" }} />
</>
),
onChange: fn(),
onRemoveChildren: fn(),
inputProps: {
"placeholder": "Type something...",
"aria-label": "pill input",
},
},
} satisfies Meta<typeof PillInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoChild: Story = { args: { children: undefined } };

View File

@@ -0,0 +1,43 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import React from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import * as stories from "./PillInput.stories";
import { PillInput } from "./PillInput";
const { Default, NoChild } = composeStories(stories);
describe("PillInput", () => {
it("renders the pill input", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders only the input without children", () => {
const { container } = render(<NoChild />);
expect(container).toMatchSnapshot();
});
it("calls onRemoveChildren when backspace is pressed and input is empty", async () => {
const user = userEvent.setup();
const mockOnRemoveChildren = jest.fn();
render(<PillInput onRemoveChildren={mockOnRemoveChildren} />);
const input = screen.getByRole("textbox");
// Focus the input and press backspace (input should be empty by default)
await user.click(input);
await user.keyboard("{Backspace}");
expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,96 @@
/*
* 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, {
type PropsWithChildren,
type JSX,
useRef,
type KeyboardEventHandler,
type HTMLAttributes,
type HTMLProps,
Children,
} from "react";
import classNames from "classnames";
import { omit } from "lodash";
import { useMergeRefs } from "react-merge-refs";
import styles from "./PillInput.module.css";
import { Flex } from "../../utils/Flex";
export interface PillInputProps extends HTMLAttributes<HTMLDivElement> {
/**
* Callback for when the user presses backspace on an empty input.
*/
onRemoveChildren?: KeyboardEventHandler;
/**
* Props to pass to the input element.
*/
inputProps?: HTMLProps<HTMLInputElement> & { "data-testid"?: string };
}
/**
* An input component that can contain multiple child elements and an input field.
*
* @example
* ```tsx
* <PillInput>
* <div>Child 1</div>
* <div>Child 2</div>
* </PillInput>
* ```
*/
export function PillInput({
className,
children,
onRemoveChildren,
inputProps,
...props
}: PropsWithChildren<PillInputProps>): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]);
const ref = useMergeRefs([inputRef, inputProps?.ref]);
const hasChildren = Children.toArray(children).length > 0;
return (
<Flex
{...props}
gap="var(--cpd-space-1x)"
direction="column"
className={classNames(styles.pillInput, className)}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
inputRef.current?.focus();
}}
>
{hasChildren && (
<Flex gap="var(--cpd-space-1x)" wrap="wrap" align="center">
{children}
</Flex>
)}
<input
ref={ref}
autoComplete="off"
className={classNames(styles.input, { [styles.largerInput]: hasChildren })}
onKeyDown={(evt) => {
const value = evt.currentTarget.value.trim();
// If the input is empty and the user presses backspace, we call the onRemoveChildren handler
if (evt.key === "Backspace" && !value) {
evt.preventDefault();
onRemoveChildren?.(evt);
return;
}
inputProps?.onKeyDown?.(evt);
}}
{...inputAttributes}
/>
</Flex>
);
}

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PillInput renders only the input without children 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<input
aria-label="pill input"
autocomplete="off"
class="input"
placeholder="Type something..."
/>
</div>
</div>
`;
exports[`PillInput renders the pill input 1`] = `
<div>
<div
class="flex pillInput"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: wrap;"
>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
<div
style="min-width: 162px; height: 28px; background-color: rgb(204, 204, 204); border-radius: 99px;"
/>
</div>
<input
aria-label="pill input"
autocomplete="off"
class="input largerInput"
placeholder="Type something..."
/>
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { PillInput } from "./PillInput";

View File

@@ -0,0 +1,76 @@
/*
* 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.
*/
.richItem {
/* Remove browser button style */
background: transparent;
border: none;
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: start;
display: grid;
column-gap: var(--cpd-space-3x);
grid-template-columns: max-content 1fr max-content;
grid-template-areas:
"avatar title time"
"avatar description time";
}
.richItem:hover,
.richItem:focus {
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 12px;
}
.richItem:not(:last-child) {
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
}
.avatar {
grid-area: avatar;
align-self: center;
}
.title {
grid-area: title;
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-primary);
}
.description {
grid-area: description;
}
.timestamp {
grid-area: time;
align-self: center;
}
.title,
.description {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.description,
.timestamp {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}
.checkmark {
grid-area: avatar;
align-self: center;
background-color: var(--cpd-color-icon-accent-primary);
width: 32px;
height: 32px;
border-radius: 100%;
}

View File

@@ -0,0 +1,64 @@
/*
* 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 { fn } from "storybook/test";
import { RichItem } from "./RichItem";
import type { Meta, StoryFn } from "@storybook/react-vite";
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();
export default {
title: "RichList/RichItem",
component: RichItem,
tags: ["autodocs"],
args: {
avatar: <div style={{ width: 32, height: 32, backgroundColor: "#ccc", borderRadius: "50%" }} />,
title: "Rich Item Title",
description: "This is a description of the rich item.",
timestamp: currentTimestamp,
onClick: fn(),
},
beforeEach: () => {
Date.now = () => new Date("2025-08-01T12:00:00Z").getTime();
},
parameters: {
a11y: {
context: "button",
},
},
} as Meta<typeof RichItem>;
const Template: StoryFn<typeof RichItem> = (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
</ul>
);
export const Default = Template.bind({});
export const Selected = Template.bind({});
Selected.args = {
selected: true,
};
export const WithoutTimestamp = Template.bind({});
WithoutTimestamp.args = {
timestamp: undefined,
};
export const Hover = Template.bind({});
Hover.parameters = { pseudo: { hover: true } };
const TemplateSeparator: StoryFn<typeof RichItem> = (args) => (
<ul role="listbox" style={{ all: "unset", listStyle: "none" }}>
<RichItem {...args} />
<RichItem {...args} />
</ul>
);
export const Separator = TemplateSeparator.bind({});

View File

@@ -0,0 +1,35 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./RichItem.stories";
const { Default, Selected, WithoutTimestamp } = composeStories(stories);
describe("RichItem", () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z"));
});
it("renders the item in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the item in selected state", () => {
const { container } = render(<Selected />);
expect(container).toMatchSnapshot();
});
it("renders the item without timestamp", () => {
const { container } = render(<WithoutTimestamp />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,96 @@
/*
* 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, { type HTMLAttributes, type JSX, memo } from "react";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RichItem.module.css";
import { humanizeTime } from "../../utils/humanize";
import { Flex } from "../../utils/Flex";
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
/**
* Avatar to display at the start of the item
*/
avatar: React.ReactNode;
/**
* Title to display at the top of the item
*/
title: string;
/**
* Description to display below the title
*/
description: string;
/**
* Timestamp to display at the end of the item
* The value is humanized (e.g. "5 minutes ago")
*/
timestamp?: number;
/**
* Whether the item is selected
* This will replace the avatar with a checkmark
* @default false
*/
selected?: boolean;
}
/**
* A rich item to display in a list, with an avatar, title, description and optional timestamp.
* If selected, the avatar is replaced with a checkmark.
* A separator is added between items in a list.
*
* @example
* ```tsx
* <RichItem
* avatar={<AvatarComponent />}
* title="Rich Item Title"
* description="This is a description of the rich item."
* timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago
* selected={true}
* onClick={() => console.log("Item clicked")}
* />
* ```
*/
export const RichItem = memo(function RichItem({
avatar,
title,
description,
timestamp,
selected,
...props
}: RichItemProps): JSX.Element {
return (
<li
className={styles.richItem}
role="option"
tabIndex={-1}
aria-selected={selected}
aria-label={title}
{...props}
>
{selected ? <Checkmark /> : <Flex className={styles.avatar}>{avatar}</Flex>}
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
{timestamp && (
<span role="timer" className={styles.timestamp}>
{humanizeTime(timestamp)}
</span>
)}
</li>
);
});
/**
* A checkmark icon inside a circle, used to indicate selection.
*/
function Checkmark(): JSX.Element {
return (
<Flex align="center" justify="center" aria-hidden="true" className={styles.checkmark}>
<CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-on-solid-primary)" />
</Flex>
);
}

View File

@@ -0,0 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RichItem renders the item in default state 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
<span
class="timestamp"
role="timer"
>
145 days ago
</span>
</li>
</ul>
</div>
`;
exports[`RichItem renders the item in selected state 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
aria-selected="true"
class="richItem"
role="option"
tabindex="-1"
>
<div
aria-hidden="true"
class="flex checkmark"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
color="var(--cpd-color-icon-on-solid-primary)"
fill="currentColor"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
<span
class="timestamp"
role="timer"
>
145 days ago
</span>
</li>
</ul>
</div>
`;
exports[`RichItem renders the item without timestamp 1`] = `
<div>
<ul
role="listbox"
style="all: unset; list-style: none;"
>
<li
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="-1"
>
<div
class="flex avatar"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
style="width: 32px; height: 32px; background-color: rgb(204, 204, 204); border-radius: 50%;"
/>
</div>
<span
class="title"
>
Rich Item Title
</span>
<span
class="description"
>
This is a description of the rich item.
</span>
</li>
</ul>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
* 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.
*/
export { RichItem } from "./RichItem";

Some files were not shown because too many files have changed in this diff Show More