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 11:06:11 +00:00
committed by GitHub
parent 82fef06895
commit eb08257b77
24 changed files with 302 additions and 122 deletions
@@ -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"