Make shared-components tiles render identically outside Element Web - # 1 (#33418)

* Apply event presentation attributes to timeline previews and errors

* Make TextualEventView render the same in web and storybook

* Make TileErrorView render the same in app/web and storybook

* Updated snapshots

* Make it possible to view shared components with default app/web base styling.

* Adjust styling and add underline to pass tests

* Fix Sonar issue

* Rename base css to root css

* Handle font styling correctly
This commit is contained in:
rbondesson
2026-05-11 13:06:11 +02:00
committed by GitHub
parent 82fef06895
commit eb08257b77
24 changed files with 302 additions and 122 deletions
@@ -7,12 +7,8 @@ Please see LICENSE files in the repository root for full details.
*/
.mx_TextualEvent {
overflow-y: hidden;
line-height: normal;
a {
color: $accent;
cursor: pointer;
}
.mx_RoomView_searchResultsPanel & {
+1 -1
View File
@@ -84,7 +84,7 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => {
const vm = new TextualEventViewModel(props);
return <TextualEventView vm={vm} />;
return <TextualEventView vm={vm} className="mx_TextualEvent" />;
};
function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
@@ -0,0 +1,71 @@
/*
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.
*/
/*
* Match the app/web shell baseline without importing app/web component styles.
* Shared component stories should inherit the same root typography and font
* feature settings as the real app, while still owning their component styling.
*/
@layer app-web {
:root[data-storybook-root-css="app-web"] {
[class^="cpd-theme"][class^="cpd-theme"] {
--cpd-font-family-sans:
"Inter", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif,
"Noto Color Emoji";
}
body {
font: var(--cpd-font-body-md-regular) !important;
letter-spacing: var(--cpd-font-letter-spacing-body-md) !important;
font-feature-settings:
"kern" 1,
"liga" 1,
"calt" 1 !important;
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary);
border: 0;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
pre,
code {
font-family:
"Fira Code", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, "Noto Color Emoji";
font-size: 100% !important;
}
}
/* 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);
}
}
@@ -8,29 +8,3 @@ 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);
}
@@ -12,6 +12,7 @@ import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "./compound.css";
import "./app-web-root.css";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -47,7 +48,7 @@ export const globalTypes = {
icon: "component",
title: "Event layout",
items: [
{ title: "Group", value: "group" },
{ title: "Modern", value: "group" },
{ title: "Bubble", value: "bubble" },
{ title: "IRC", value: "irc" },
],
@@ -65,16 +66,47 @@ export const globalTypes = {
],
},
},
rootCss: {
name: "Root CSS",
description: "Global root CSS for component previews",
toolbar: {
icon: "paintbrush",
title: "Root CSS",
items: [
{ title: "Default", value: "storybook" },
{ title: "Element Web", value: "app-web" },
],
},
},
initialGlobals: {
theme: "system",
theme: "light",
language: "en",
eventLayout: "group",
eventDensity: "default",
rootCss: "storybook",
},
} satisfies ArgTypes;
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
const RootCssSwitcher: React.FC<{
rootCss: string;
}> = ({ rootCss }) => {
useLayoutEffect(() => {
if (rootCss === "app-web") {
document.documentElement.dataset.storybookRootCss = rootCss;
} else {
delete document.documentElement.dataset.storybookRootCss;
}
return () => {
delete document.documentElement.dataset.storybookRootCss;
};
}, [rootCss]);
return null;
};
const ThemeSwitcher: React.FC<{
theme: string;
}> = ({ theme }) => {
@@ -89,6 +121,15 @@ const ThemeSwitcher: React.FC<{
return null;
};
const withRootCss: Decorator = (Story, context) => {
return (
<>
<RootCssSwitcher rootCss={context.globals.rootCss} />
<Story />
</>
);
};
const withThemeProvider: Decorator = (Story, context) => {
return (
<>
@@ -134,12 +175,13 @@ const withEventPresentationProvider: Decorator = (Story, context) => {
const preview = {
tags: ["autodocs", "snapshot"],
initialGlobals: {
theme: "system",
rootCss: "storybook",
theme: "light",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
decorators: [withRootCss, withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
parameters: {
options: {
storySort: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -37,3 +37,20 @@ export const EventPresentationProvider = EventPresentationContext.Provider;
export function useEventPresentation(): EventPresentation {
return useContext(EventPresentationContext);
}
/** Returns the current event presentation settings attributes.
*
* "data-event-layout": "group" | "bubble" | "irc"
* "data-event-density": "default" | "compact"
*/
export function useEventPresentationAttributes(): {
"data-event-layout": EventLayout;
"data-event-density": EventDensity;
} {
const { layout, density } = useEventPresentation();
return {
"data-event-layout": layout,
"data-event-density": density,
};
}
@@ -0,0 +1,25 @@
/*
* 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.
*/
.textualEvent {
font-size: var(--cpd-font-size-body-sm);
line-height: normal;
overflow-y: hidden;
color: var(--cpd-color-text-secondary);
a {
cursor: pointer;
color: var(--cpd-color-text-action-accent);
}
}
.textualEvent[data-event-layout="irc"] {
padding: 1px 0; /* add a 1px padding top and bottom because our larger emoji font otherwise gets cropped by anti-zalgo */
display: inline-block;
line-height: 1.125rem; /* $font-18px equivalent of the legacy --irc-line-height */
}
@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Meta, type StoryObj } from "@storybook/react-vite";
import React from "react";
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
@@ -23,3 +24,16 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithLink: Story = {
args: {
vm: new MockViewModel({
content: (
<>
<span>Dummy [🤒] textual event text </span>
<a href="~">with link</a>
</>
),
}),
},
};
@@ -12,11 +12,16 @@ import { describe, it, expect } from "vitest";
import * as stories from "./TextualEventView.stories.tsx";
const { Default } = composeStories(stories);
const { Default, WithLink } = composeStories(stories);
describe("TextualEventView", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a textual event with link", () => {
const { container } = render(<WithLink />);
expect(container).toMatchSnapshot();
});
});
@@ -6,15 +6,22 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode, type JSX } from "react";
import classNames from "classnames";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./TextualEventView.module.css";
import { useEventPresentationAttributes } from "../../../EventPresentation/EventPresentationContext";
export type TextualEventViewSnapshot = {
content: string | ReactNode;
};
export interface Props {
/** The view model for the tile error fallback. */
vm: ViewModel<TextualEventViewSnapshot>;
/** Optional host-level class names. */
className?: string;
}
/**
@@ -23,7 +30,12 @@ export interface Props {
* This view is used for simple informational timeline entries where the
* content is already prepared by the view model.
*/
export function TextualEventView({ vm }: Props): JSX.Element {
export function TextualEventView({ vm, className }: Readonly<Props>): JSX.Element {
const eventPresentationAttributes = useEventPresentationAttributes();
const snapshot = useViewModel(vm);
return <div className="mx_TextualEvent">{snapshot.content}</div>;
return (
<div className={classNames(styles.textualEvent, className)} {...eventPresentationAttributes}>
{snapshot.content}
</div>
);
}
@@ -3,9 +3,30 @@
exports[`TextualEventView > renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"
class="TextualEventView-module_textualEvent"
data-event-density="default"
data-event-layout="group"
>
Dummy textual event text
</div>
</div>
`;
exports[`TextualEventView > renders a textual event with link 1`] = `
<div>
<div
class="TextualEventView-module_textualEvent"
data-event-density="default"
data-event-layout="group"
>
<span>
Dummy [🤒] textual event text
</span>
<a
href="~"
>
with link
</a>
</div>
</div>
`;
@@ -1,38 +1,42 @@
.tileErrorView {
color: var(--cpd-color-text-critical-primary);
font-size: var(--cpd-font-size-body-sm);
color: var(--cpd-color-text-secondary);
list-style: none;
text-align: center;
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
.message {
padding: var(--cpd-space-1x) var(--cpd-space-4x);
}
.viewSourceButton {
appearance: none;
padding: 0;
border: 0;
background: none;
color: var(--cpd-color-text-action-primary);
cursor: pointer;
font: inherit;
text-decoration: underline;
}
.viewSourceButton:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
}
}
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
.bubble .line {
flex-direction: column;
}
.message {
padding: var(--cpd-space-1x) var(--cpd-space-4x);
}
.viewSourceButton {
appearance: none;
padding: 0;
border: 0;
background: none;
color: var(--cpd-color-text-action-primary);
cursor: pointer;
font: inherit;
text-decoration: underline;
}
.viewSourceButton:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
.tileErrorView[data-event-layout="bubble"] {
.line {
flex-direction: column;
}
}
@@ -11,7 +11,6 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { EventPresentationProvider } from "../../../EventPresentation";
import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView";
type WrapperProps = TileErrorViewSnapshot &
@@ -59,13 +58,9 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const BubbleLayout: Story = {
decorators: [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "bubble", density: "default" }}>
<Story />
</EventPresentationProvider>
),
],
globals: {
eventLayout: "bubble",
},
};
export const WithoutActions: Story = {
@@ -10,8 +10,8 @@ import classNames from "classnames";
import { Button } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useEventPresentation } from "../../../EventPresentation";
import styles from "./TileErrorView.module.css";
import { useEventPresentationAttributes } from "../../../EventPresentation/EventPresentationContext";
/** Snapshot data for rendering an event tile error fallback. */
export interface TileErrorViewSnapshot {
@@ -55,14 +55,11 @@ interface TileErrorViewProps {
* actions when their labels are provided.
*/
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element {
const { layout } = useEventPresentation();
const eventPresentationAttributes = useEventPresentationAttributes();
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel } = useViewModel(vm);
return (
<li
className={classNames(styles.tileErrorView, className, { [styles.bubble]: layout === "bubble" })}
data-layout={layout}
>
<li className={classNames(styles.tileErrorView, className)} {...eventPresentationAttributes}>
<div className={styles.line} role="status">
<span className={styles.message}>
{message}
@@ -4,8 +4,9 @@ exports[`TileErrorView > renders the bubble layout variant 1`] = `
<div>
<ul>
<li
class="TileErrorView-module_tileErrorView TileErrorView-module_bubble"
data-layout="bubble"
class="TileErrorView-module_tileErrorView"
data-event-density="default"
data-event-layout="group"
>
<div
class="TileErrorView-module_line"
@@ -43,7 +44,8 @@ exports[`TileErrorView > renders the default tile error state 1`] = `
<ul>
<li
class="TileErrorView-module_tileErrorView"
data-layout="group"
data-event-density="default"
data-event-layout="group"
>
<div
class="TileErrorView-module_line"
@@ -5,29 +5,31 @@
* Please see LICENSE files in the repository root for full details.
*/
.previewGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-4x);
&.compactLayout {
gap: var(--cpd-space-2x);
margin-bottom: var(--cpd-space-2x);
}
.toggleButton[data-kind="tertiary"] {
margin-left: auto;
margin-right: auto;
text-decoration: none;
color: var(--cpd-color-icon-accent-primary);
font-weight: var(--cpd-font-weight-regular);
}
}
.wrapper {
margin-top: var(--cpd-space-4x);
display: flex;
flex-direction: row;
.previewGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-4x);
.toggleButton[data-kind="tertiary"] {
margin-left: auto;
margin-right: auto;
text-decoration: none;
color: var(--cpd-color-icon-accent-primary);
font-weight: var(--cpd-font-weight-regular);
}
}
}
.wrapper[data-event-density="compact"] {
.previewGroup {
gap: var(--cpd-space-2x);
margin-bottom: var(--cpd-space-2x);
}
}
@@ -19,7 +19,6 @@ import {
import { useMockedViewModel } from "../../../../core/viewmodel";
import { LinkedTextContext } from "../../../../core/utils/LinkedText";
import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { EventPresentationProvider } from "../../EventPresentation";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
@@ -142,10 +141,6 @@ export const WithCompactView = Template.bind({});
WithCompactView.args = {
...MultiplePreviewsVisible.args,
};
WithCompactView.decorators = [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "group", density: "compact" }}>
<Story />
</EventPresentationProvider>
),
];
WithCompactView.globals = {
eventDensity: "compact",
};
@@ -12,10 +12,10 @@ import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import { useI18n } from "../../../../core/i18n/i18nContext";
import { useEventPresentation } from "../../EventPresentation";
import type { UrlPreview } from "./types";
import { LinkPreview } from "./LinkPreview";
import styles from "./UrlPreviewGroupView.module.css";
import { useEventPresentationAttributes } from "../../EventPresentation/EventPresentationContext";
/** Snapshot data for rendering URL previews attached to an event. */
export interface UrlPreviewGroupViewSnapshot {
@@ -62,7 +62,7 @@ export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, Ur
*/
export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n();
const { density } = useEventPresentation();
const eventPresentationAttributes = useEventPresentationAttributes();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm);
if (previews.length === 0) {
return null;
@@ -80,8 +80,8 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
}
return (
<div className={classNames(className, styles.wrapper)}>
<div className={classNames(styles.previewGroup, density === "compact" && styles.compactLayout)}>
<div className={classNames(className, styles.wrapper)} {...eventPresentationAttributes}>
<div className={styles.previewGroup}>
{previews.map((preview) => (
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
))}
@@ -4,6 +4,8 @@ exports[`UrlPreviewGroupView > renders a single preview 1`] = `
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
data-event-density="default"
data-event-layout="group"
>
<div
class="UrlPreviewGroupView-module_previewGroup"
@@ -82,6 +84,8 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
data-event-density="default"
data-event-layout="group"
>
<div
class="UrlPreviewGroupView-module_previewGroup"
@@ -249,6 +253,8 @@ exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] =
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
data-event-density="default"
data-event-layout="group"
>
<div
class="UrlPreviewGroupView-module_previewGroup"
@@ -336,9 +342,11 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
data-event-density="default"
data-event-layout="group"
>
<div
class="UrlPreviewGroupView-module_previewGroup UrlPreviewGroupView-module_compactLayout"
class="UrlPreviewGroupView-module_previewGroup"
>
<div
class="LinkPreview-module_container"