Make shared components aware of layout and density settings (#33368)

* Add shared event presentation context

* Add app-web event presentation mapper

* Wire event presentation provider into app timelines

* Add Storybook controls for event layout and density

* Wire compact density through app/web event presentation provider

* Use event presentation density for URL previews

* Move TileErrorView layout to event presentation context

* Minor fix and updated snapshot

* Updated snapshots for url preview group

* Prettier fix

* Restore removed story to fix missing playwright test

* Updates after review comments

* Fix prettier issue
This commit is contained in:
rbondesson
2026-05-06 07:30:46 +02:00
committed by GitHub
parent 7e26c2d144
commit 1f71a3a3fe
23 changed files with 379 additions and 209 deletions
@@ -1,4 +1,11 @@
import type { ArgTypes, Preview, Decorator, ReactRenderer, StrictArgs } from "@storybook/react-vite";
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import type { ArgTypes, Decorator, Preview, ReactRenderer, StrictArgs } from "@storybook/react-vite";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
@@ -7,10 +14,11 @@ import "@fontsource/inter/700.css";
import "./compound.css";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/core/i18n/i18n";
import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf";
import { I18nApi, I18nContext } from "../src";
import type { StoryContext } from "storybook/internal/csf";
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
import { setLanguage } from "../src/core/i18n/i18n";
export const globalTypes = {
theme: {
@@ -32,9 +40,36 @@ export const globalTypes = {
name: "Language",
description: "Global language for components",
},
eventLayout: {
name: "Event layout",
description: "Global event layout for timeline components",
toolbar: {
icon: "component",
title: "Event layout",
items: [
{ title: "Group", value: "group" },
{ title: "Bubble", value: "bubble" },
{ title: "IRC", value: "irc" },
],
},
},
eventDensity: {
name: "Event density",
description: "Global event density for timeline components",
toolbar: {
icon: "listunordered",
title: "Event density",
items: [
{ title: "Default", value: "default" },
{ title: "Compact", value: "compact" },
],
},
},
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
} satisfies ArgTypes;
@@ -83,9 +118,28 @@ const withI18nProvider: Decorator = (Story) => {
);
};
const preview: Preview = {
const withEventPresentationProvider: Decorator = (Story, context) => {
return (
<EventPresentationProvider
value={{
layout: context.globals.eventLayout as EventLayout,
density: context.globals.eventDensity as EventDensity,
}}
>
<Story />
</EventPresentationProvider>
);
};
const preview = {
tags: ["autodocs", "snapshot"],
decorators: [withThemeProvider, withTooltipProvider, withI18nProvider],
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
parameters: {
options: {
storySort: {
@@ -101,6 +155,6 @@ const preview: Preview = {
},
},
loaders: [languageLoader],
};
} satisfies Preview;
export default preview;
+1
View File
@@ -14,6 +14,7 @@ export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./crypto/SasEmoji";
export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/EventPresentation";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView";
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { createContext, useContext } from "react";
/** Event tile layout selected by the host surface. */
export type EventLayout = "group" | "bubble" | "irc";
/** Density variant applied within an event layout. */
export type EventDensity = "default" | "compact";
/** Presentation settings that shared event/timeline components can adapt to. */
export interface EventPresentation {
/** Layout family used for event rendering. */
layout: EventLayout;
/** Spacing density used within the layout. */
density: EventDensity;
}
/** Default event presentation used when no provider is present. */
export const DEFAULT_EVENT_PRESENTATION: EventPresentation = {
layout: "group",
density: "default",
};
const EventPresentationContext = createContext<EventPresentation>(DEFAULT_EVENT_PRESENTATION);
EventPresentationContext.displayName = "EventPresentationContext";
/** Provides event presentation settings to shared event/timeline components. */
export const EventPresentationProvider = EventPresentationContext.Provider;
/** Returns the current event presentation settings. */
export function useEventPresentation(): EventPresentation {
return useContext(EventPresentationContext);
}
@@ -0,0 +1,15 @@
/*
* 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.
*/
export {
DEFAULT_EVENT_PRESENTATION,
EventPresentationProvider,
useEventPresentation,
type EventDensity,
type EventLayout,
type EventPresentation,
} from "./EventPresentationContext";
@@ -11,6 +11,7 @@ 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 &
@@ -49,7 +50,6 @@ const meta = {
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
layout: "group",
},
} satisfies Meta<typeof TileErrorViewWrapper>;
@@ -59,9 +59,13 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const BubbleLayout: Story = {
args: {
layout: "bubble",
},
decorators: [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "bubble", density: "default" }}>
<Story />
</EventPresentationProvider>
),
],
};
export const WithoutActions: Story = {
@@ -58,7 +58,6 @@ describe("TileErrorView", () => {
const vm = new TestTileErrorViewModel(
{
layout: "group",
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
@@ -81,7 +80,6 @@ describe("TileErrorView", () => {
it("applies a custom className to the root element", () => {
const vm = new MockViewModel<TileErrorViewSnapshot>({
layout: "group",
message: "Can't load this message",
}) as TileErrorViewModel;
@@ -10,44 +10,30 @@ 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";
export type TileErrorViewLayout = "bubble" | "group" | "irc";
/** Snapshot data for rendering an event tile error fallback. */
export interface TileErrorViewSnapshot {
/**
* Layout variant used by the host timeline.
*/
layout?: TileErrorViewLayout;
/**
* Primary fallback text shown when a tile fails to render.
*/
/** Primary fallback text shown when a tile fails to render. */
message: string;
/**
* Optional event type appended to the fallback text.
*/
/** Optional event type appended to the fallback text. */
eventType?: string;
/**
* Optional label for the bug-report action button.
*/
/** Optional label for the bug-report action button. */
bugReportCtaLabel?: string;
/**
* Optional label for the view-source action.
*/
/** Optional label for the view-source action. */
viewSourceCtaLabel?: string;
}
/** User actions emitted by the tile error fallback. */
export interface TileErrorViewActions {
/**
* Invoked when the bug-report button is clicked.
*/
/** Invoked when the bug-report button is clicked. */
onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked when the view-source action is clicked.
*/
/** Invoked when the view-source action is clicked. */
onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
}
/** View model contract for the tile error fallback. */
export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>;
interface TileErrorViewProps {
@@ -66,11 +52,11 @@ interface TileErrorViewProps {
*
* The component shows the fallback error message from the view model, optionally
* appends the event type in parentheses, and can render bug-report and view-source
* actions when their labels are provided. The layout in the view-model snapshot
* selects the timeline presentation variant.
* actions when their labels are provided.
*/
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element {
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel, layout = "group" } = useViewModel(vm);
const { layout } = useEventPresentation();
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel } = useViewModel(vm);
return (
<li
@@ -8,7 +8,6 @@
export {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewLayout,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";
@@ -19,6 +19,7 @@ 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;
@@ -94,7 +95,7 @@ MultiplePreviewsVisible.args = {
{
title: "One",
description: "A regular square image.",
link: "https://matrix.org",
link: "https://matrix.org/one",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@@ -108,7 +109,7 @@ MultiplePreviewsVisible.args = {
{
title: "Two",
description: "This one has a taller image which should crop nicely.",
link: "https://matrix.org",
link: "https://matrix.org/two",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@@ -121,7 +122,7 @@ MultiplePreviewsVisible.args = {
{
title: "Three",
description: "One more description",
link: "https://matrix.org",
link: "https://matrix.org/three",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@@ -140,5 +141,11 @@ MultiplePreviewsVisible.args = {
export const WithCompactView = Template.bind({});
WithCompactView.args = {
...MultiplePreviewsVisible.args,
compactLayout: true,
};
WithCompactView.decorators = [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "group", density: "compact" }}>
<Story />
</EventPresentationProvider>
),
];
@@ -27,7 +27,7 @@ describe("UrlPreviewGroupView", () => {
const { container } = render(<MultiplePreviewsHidden />);
expect(container).toMatchSnapshot();
});
it("renders with a compact view", () => {
it("renders with compact density", () => {
const { container } = render(<WithCompactView />);
expect(container).toMatchSnapshot();
});
@@ -12,18 +12,24 @@ 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";
/** Snapshot data for rendering URL previews attached to an event. */
export interface UrlPreviewGroupViewSnapshot {
/** URL previews to render. */
previews: Array<UrlPreview>;
/** Total number of previews available before limiting. */
totalPreviewCount: number;
/** Whether the preview list is currently limited. */
previewsLimited: boolean;
/** Whether more previews exist than are currently rendered. */
overPreviewLimit: boolean;
compactLayout: boolean;
}
/** Props for the URL preview group view. */
export interface UrlPreviewGroupViewProps {
/**
* The view model for the component.
@@ -35,12 +41,17 @@ export interface UrlPreviewGroupViewProps {
className?: string;
}
/** User actions emitted by the URL preview group view. */
export interface UrlPreviewGroupViewActions {
/** Invoked when the preview limit toggle is clicked. */
onTogglePreviewLimit: () => void;
/** Invoked when the hide-preview action is clicked. */
onHideClick: () => Promise<void>;
/** Invoked when a preview image is clicked. */
onImageClick: (preview: UrlPreview) => void;
}
/** View model contract for the URL preview group view. */
export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewActions>;
/**
@@ -51,7 +62,8 @@ export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, Ur
*/
export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit, compactLayout } = useViewModel(vm);
const { density } = useEventPresentation();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm);
if (previews.length === 0) {
return null;
}
@@ -69,7 +81,7 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
return (
<div className={classNames(className, styles.wrapper)}>
<div className={classNames(styles.previewGroup, compactLayout && styles.compactLayout)}>
<div className={classNames(styles.previewGroup, density === "compact" && styles.compactLayout)}>
{previews.map((preview) => (
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
))}
@@ -104,7 +104,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/one"
rel="noreferrer noopener"
target="_blank"
>
@@ -144,7 +144,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/two"
rel="noreferrer noopener"
target="_blank"
>
@@ -184,7 +184,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/three"
rel="noreferrer noopener"
target="_blank"
>
@@ -332,7 +332,7 @@ exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] =
</div>
`;
exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
exports[`UrlPreviewGroupView > renders with compact density 1`] = `
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
@@ -358,7 +358,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/one"
rel="noreferrer noopener"
target="_blank"
>
@@ -398,7 +398,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/two"
rel="noreferrer noopener"
target="_blank"
>
@@ -438,7 +438,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/three"
rel="noreferrer noopener"
target="_blank"
>