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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
+39
@@ -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";
|
||||
+8
-4
@@ -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 = {
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
|
||||
+13
-27
@@ -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
|
||||
|
||||
-1
@@ -8,7 +8,6 @@
|
||||
export {
|
||||
TileErrorView,
|
||||
type TileErrorViewActions,
|
||||
type TileErrorViewLayout,
|
||||
type TileErrorViewModel,
|
||||
type TileErrorViewSnapshot,
|
||||
} from "./TileErrorView";
|
||||
|
||||
+11
-4
@@ -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>
|
||||
),
|
||||
];
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
|
||||
+15
-3
@@ -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} />
|
||||
))}
|
||||
|
||||
+7
-7
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user