Refactor and move MVideoBody to shared components (#32849)
* init MVideoBody to shared components, including test, stories and view * fix prettier and other warnings * move video message body to shared view + app viewmodel * Fix prettier warnings and masking spinner for tests * stabilize VideoBodyView screenshots with local media asset * Disable spinner from changing image all the time * Added mask over video spinner to prevent issues with new generated images on playwright tests * Update prettier fix * Update snapshot * Add tests to cover different states of Video * Update code to prevent the previous component Hack fix regarding jumps on the timeline. * Update snapshot * Update code to improve code quality for Sonar + update snapshot * adde documentation snippets * refactor: move m.video rendering into body factory * docs: add tsdoc for video body view model * docs: add thumbnail tsdoc for video body view model * docs: add content-url tsdoc for video body view model * docs: add dimensions tsdoc for video body view model * docs: add aspect-ratio tsdoc for video body view model * docs: add tsdoc for video body view state * refactor: replace video body view state enum * refactor: remove duplicate video body state init * refactor: drop unused video body view state attribute * Fix Prettier * Update snapshot screenshot * test: restore video story screenshot mask * chore: refresh PR head * Add mask to screenshot to pass CI tests * test: narrow video story mask hook * Fix easy Sonar warnings in video body components * Move shared message body views into event-tile layout * Move shared message body visual baselines * Revert unrelated shared message body moves
This commit is contained in:
@@ -25,6 +25,24 @@ vis.setup({
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
}
|
||||
/*
|
||||
* Mask spinner for video overlay during screenshot generation on playwright tests.
|
||||
*/
|
||||
[data-video-body-mask-target] {
|
||||
position: relative;
|
||||
}
|
||||
[data-video-body-mask-target]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 50%;
|
||||
inset-block-start: 50%;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 999px;
|
||||
background: #ff4fcf;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Hide all storybook elements */
|
||||
.sb-wrapper {
|
||||
visibility: hidden !important;
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -16,6 +16,7 @@ export * from "./event-tiles/UrlPreviewGroupView";
|
||||
export * from "./message-body/EventContentBody";
|
||||
export * from "./message-body/RedactedBodyView";
|
||||
export * from "./message-body/FileBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MVideoBodyView";
|
||||
export * from "./core/pill-input/Pill";
|
||||
export * from "./core/pill-input/PillInput";
|
||||
export * from "./room/RoomStatusBar";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
.root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
border-radius: var(--MBody-border-radius);
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hiddenButton {
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hiddenButtonContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hiddenButtonContent > svg {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-1x);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 React, { type ReactNode } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import posterImage from "../../../../../../static/element.png";
|
||||
import {
|
||||
VideoBodyView,
|
||||
VideoBodyViewState,
|
||||
type VideoBodyViewActions,
|
||||
type VideoBodyViewSnapshot,
|
||||
} from "./VideoBodyView";
|
||||
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
|
||||
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||
|
||||
const demoVideo = new URL("../../../../../../static/videoBodyDemo.webm", import.meta.url).href;
|
||||
|
||||
type VideoBodyViewProps = VideoBodyViewSnapshot &
|
||||
VideoBodyViewActions & {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const VideoBodyViewWrapperImpl = ({
|
||||
onPreviewClick,
|
||||
onPlay,
|
||||
className,
|
||||
children,
|
||||
...snapshotProps
|
||||
}: VideoBodyViewProps): ReactNode => {
|
||||
const vm = useMockedViewModel(snapshotProps, { onPreviewClick, onPlay });
|
||||
|
||||
return (
|
||||
<VideoBodyView vm={vm} className={className}>
|
||||
{children}
|
||||
</VideoBodyView>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoBodyViewWrapper = withViewDocs(VideoBodyViewWrapperImpl, VideoBodyView);
|
||||
|
||||
const meta = {
|
||||
title: "MessageBody/VideoBodyView",
|
||||
component: VideoBodyViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
state: {
|
||||
options: Object.entries(VideoBodyViewState)
|
||||
.filter(([key, value]) => key === value)
|
||||
.map(([key]) => key),
|
||||
control: { type: "select" },
|
||||
},
|
||||
className: { control: "text" },
|
||||
},
|
||||
args: {
|
||||
state: VideoBodyViewState.READY,
|
||||
videoLabel: "Product demo video",
|
||||
hiddenButtonLabel: "Show video",
|
||||
errorLabel: "Error decrypting video",
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
aspectRatio: "16/9",
|
||||
src: demoVideo,
|
||||
poster: posterImage,
|
||||
preload: "none",
|
||||
controls: true,
|
||||
muted: false,
|
||||
autoPlay: false,
|
||||
className: undefined,
|
||||
children: <div>File body slot</div>,
|
||||
},
|
||||
} satisfies Meta<typeof VideoBodyViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Ready: Story = {};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
state: VideoBodyViewState.HIDDEN,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
state: VideoBodyViewState.LOADING,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
state: VideoBodyViewState.ERROR,
|
||||
},
|
||||
};
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { fireEvent, render, screen } from "@test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
|
||||
import * as stories from "./VideoBodyView.stories";
|
||||
import {
|
||||
VideoBodyView,
|
||||
VideoBodyViewState,
|
||||
type VideoBodyViewActions,
|
||||
type VideoBodyViewSnapshot,
|
||||
} from "./VideoBodyView";
|
||||
|
||||
const { Ready, Hidden, ErrorState } = composeStories(stories);
|
||||
|
||||
class TestVideoBodyViewModel extends MockViewModel<VideoBodyViewSnapshot> implements VideoBodyViewActions {
|
||||
public onPreviewClick?: VideoBodyViewActions["onPreviewClick"];
|
||||
public onPlay?: VideoBodyViewActions["onPlay"];
|
||||
|
||||
public constructor(snapshot: VideoBodyViewSnapshot, actions: VideoBodyViewActions = {}) {
|
||||
super(snapshot);
|
||||
this.onPreviewClick = actions.onPreviewClick;
|
||||
this.onPlay = actions.onPlay;
|
||||
}
|
||||
}
|
||||
|
||||
describe("VideoBodyView", () => {
|
||||
it.each([
|
||||
["ready", Ready],
|
||||
["hidden", Hidden],
|
||||
["error", ErrorState],
|
||||
])("matches snapshot for %s story", (_name, Story) => {
|
||||
const { container } = render(<Story />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the hidden preview button and wires the click handler", () => {
|
||||
const onPreviewClick = vi.fn();
|
||||
const vm = new TestVideoBodyViewModel(
|
||||
{
|
||||
state: VideoBodyViewState.HIDDEN,
|
||||
hiddenButtonLabel: "Show video",
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
aspectRatio: "16/9",
|
||||
},
|
||||
{ onPreviewClick },
|
||||
);
|
||||
|
||||
render(<VideoBodyView vm={vm} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show video" }));
|
||||
expect(onPreviewClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a loading spinner while the media is being prepared", () => {
|
||||
const vm = new TestVideoBodyViewModel({
|
||||
state: VideoBodyViewState.LOADING,
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
aspectRatio: "16/9",
|
||||
});
|
||||
|
||||
render(<VideoBodyView vm={vm} />);
|
||||
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error message when media processing fails", () => {
|
||||
const vm = new TestVideoBodyViewModel({
|
||||
state: VideoBodyViewState.ERROR,
|
||||
errorLabel: "Error decrypting video",
|
||||
});
|
||||
|
||||
render(<VideoBodyView vm={vm} />);
|
||||
|
||||
expect(screen.getByText("Error decrypting video")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a video element with the expected attributes and file body content", () => {
|
||||
const onPlay = vi.fn();
|
||||
const vm = new TestVideoBodyViewModel(
|
||||
{
|
||||
state: VideoBodyViewState.READY,
|
||||
videoLabel: "Product demo video",
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
aspectRatio: "16/9",
|
||||
src: "https://example.org/demo.mp4",
|
||||
poster: "https://example.org/demo-poster.jpg",
|
||||
preload: "none",
|
||||
controls: true,
|
||||
muted: true,
|
||||
autoPlay: true,
|
||||
},
|
||||
{ onPlay },
|
||||
);
|
||||
|
||||
const videoRef = React.createRef<HTMLVideoElement>();
|
||||
render(
|
||||
<VideoBodyView vm={vm} videoRef={videoRef}>
|
||||
<div>File body slot</div>
|
||||
</VideoBodyView>,
|
||||
);
|
||||
|
||||
const video = screen.getByLabelText("Product demo video") as HTMLVideoElement;
|
||||
expect(video).toHaveAttribute("src", "https://example.org/demo.mp4");
|
||||
expect(video).toHaveAttribute("poster", "https://example.org/demo-poster.jpg");
|
||||
expect(video).toHaveAttribute("preload", "none");
|
||||
expect(video).toHaveAttribute("controlslist", "nodownload");
|
||||
expect(video).toHaveAttribute("crossorigin", "anonymous");
|
||||
expect(video.muted).toBe(true);
|
||||
expect(video.autoplay).toBe(true);
|
||||
expect(videoRef.current).toBe(video);
|
||||
expect(screen.getByText("File body slot")).toBeInTheDocument();
|
||||
|
||||
fireEvent.play(video);
|
||||
expect(onPlay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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 React, {
|
||||
type CSSProperties,
|
||||
type JSX,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactEventHandler,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { FileErrorIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import styles from "./VideoBodyView.module.css";
|
||||
|
||||
/**
|
||||
* Render states for the shared video body view.
|
||||
*/
|
||||
export const VideoBodyViewState = {
|
||||
ERROR: "ERROR",
|
||||
HIDDEN: "HIDDEN",
|
||||
LOADING: "LOADING",
|
||||
READY: "READY",
|
||||
} as const;
|
||||
|
||||
export type VideoBodyViewState = (typeof VideoBodyViewState)[keyof typeof VideoBodyViewState];
|
||||
|
||||
export interface VideoBodyViewSnapshot {
|
||||
/**
|
||||
* The current render state of the component.
|
||||
*/
|
||||
state: VideoBodyViewState;
|
||||
/**
|
||||
* Accessible label applied to the video element.
|
||||
*/
|
||||
videoLabel?: string;
|
||||
/**
|
||||
* Title applied to the video element.
|
||||
*/
|
||||
videoTitle?: string;
|
||||
/**
|
||||
* Label shown in the hidden-preview placeholder.
|
||||
*/
|
||||
hiddenButtonLabel?: string;
|
||||
/**
|
||||
* Label rendered when media cannot be processed.
|
||||
*/
|
||||
errorLabel?: string;
|
||||
/**
|
||||
* Optional width constraint for the media frame.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
/**
|
||||
* Optional height constraint for the media frame.
|
||||
*/
|
||||
maxHeight?: number;
|
||||
/**
|
||||
* Optional aspect ratio for the media frame.
|
||||
*/
|
||||
aspectRatio?: CSSProperties["aspectRatio"];
|
||||
/**
|
||||
* Video source URL.
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* Poster image URL.
|
||||
*/
|
||||
poster?: string;
|
||||
/**
|
||||
* Preload mode for the video.
|
||||
*/
|
||||
preload?: "none" | "metadata" | "auto";
|
||||
/**
|
||||
* Whether native controls are visible.
|
||||
*/
|
||||
controls?: boolean;
|
||||
/**
|
||||
* Whether the video is muted.
|
||||
*/
|
||||
muted?: boolean;
|
||||
/**
|
||||
* Whether the video should autoplay.
|
||||
*/
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoBodyViewActions {
|
||||
/**
|
||||
* Invoked when the user chooses to reveal hidden media.
|
||||
*/
|
||||
onPreviewClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Invoked when the video starts playing.
|
||||
*/
|
||||
onPlay?: ReactEventHandler<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
export type VideoBodyViewModel = ViewModel<VideoBodyViewSnapshot, VideoBodyViewActions>;
|
||||
|
||||
interface VideoBodyViewProps {
|
||||
/**
|
||||
* View model providing render state and actions.
|
||||
*/
|
||||
vm: VideoBodyViewModel;
|
||||
/**
|
||||
* Optional host CSS class.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional CSS class applied to the media frame container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
/**
|
||||
* Optional ref to the rendered video element.
|
||||
*/
|
||||
videoRef?: Ref<HTMLVideoElement>;
|
||||
/**
|
||||
* Optional supplemental content rendered after the video frame.
|
||||
*/
|
||||
children?: PropsWithChildren["children"];
|
||||
}
|
||||
|
||||
export function VideoBodyView({
|
||||
vm,
|
||||
className,
|
||||
containerClassName,
|
||||
videoRef,
|
||||
children,
|
||||
}: Readonly<VideoBodyViewProps>): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
videoLabel,
|
||||
videoTitle,
|
||||
hiddenButtonLabel,
|
||||
errorLabel,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
aspectRatio,
|
||||
src,
|
||||
poster,
|
||||
preload,
|
||||
controls,
|
||||
muted,
|
||||
autoPlay,
|
||||
} = useViewModel(vm);
|
||||
|
||||
const rootClassName = classNames(className, styles.root);
|
||||
const resolvedContainerClassName = classNames(containerClassName, styles.container);
|
||||
|
||||
// Reserve the media box on the container itself so the timeline doesn't jump
|
||||
// while the video element or loading state is still settling.
|
||||
const resolvedWidth = maxWidth === undefined ? undefined : `min(100%, ${maxWidth}px)`;
|
||||
const containerStyle: CSSProperties = {
|
||||
width: resolvedWidth,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
aspectRatio,
|
||||
};
|
||||
|
||||
if (state === VideoBodyViewState.ERROR) {
|
||||
return (
|
||||
<span className={classNames(rootClassName, styles.error)}>
|
||||
<FileErrorIcon width="16" height="16" />
|
||||
{errorLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === VideoBodyViewState.HIDDEN) {
|
||||
return (
|
||||
<span className={rootClassName}>
|
||||
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||
<button type="button" onClick={vm.onPreviewClick} className={styles.hiddenButton}>
|
||||
<div className={styles.hiddenButtonContent}>
|
||||
<VisibilityOnIcon />
|
||||
<span>{hiddenButtonLabel}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === VideoBodyViewState.LOADING) {
|
||||
return (
|
||||
<span className={rootClassName}>
|
||||
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<InlineSpinner aria-label="Loading..." role="progressbar" />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={rootClassName}>
|
||||
<div className={resolvedContainerClassName} style={containerStyle} data-video-body-mask-target="">
|
||||
{/* Captions will be supplied from app-side data once the VM wiring is in place. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
className={styles.video}
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
aria-label={videoLabel}
|
||||
title={videoTitle}
|
||||
controls={controls}
|
||||
controlsList="nodownload"
|
||||
crossOrigin="anonymous"
|
||||
preload={preload}
|
||||
muted={muted}
|
||||
autoPlay={autoPlay}
|
||||
poster={poster}
|
||||
onPlay={vm.onPlay}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`VideoBodyView > matches snapshot for error story 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="root error"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V4q0-.824.588-1.412A1.93 1.93 0 0 1 6 2h7.175a1.98 1.98 0 0 1 1.4.575l4.85 4.85q.275.275.425.638.15.361.15.762v3.516A6 6 0 0 0 18 12V9h-4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 8V4H6v16h6.341c.264.745.67 1.423 1.187 2z"
|
||||
/>
|
||||
<path
|
||||
d="M18 14a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1m-1 7a1 1 0 1 1 2 0 1 1 0 0 1-2 0"
|
||||
/>
|
||||
</svg>
|
||||
Error decrypting video
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`VideoBodyView > matches snapshot for hidden story 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="root"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
style="width: min(100%, 320px); max-width: 320px; max-height: 180px; aspect-ratio: 16 / 9;"
|
||||
>
|
||||
<button
|
||||
class="hiddenButton"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="hiddenButtonContent"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Show video
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`VideoBodyView > matches snapshot for ready story 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="root"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
data-video-body-mask-target=""
|
||||
style="width: min(100%, 320px); max-width: 320px; max-height: 180px; aspect-ratio: 16 / 9;"
|
||||
>
|
||||
<video
|
||||
aria-label="Product demo video"
|
||||
class="video"
|
||||
controls=""
|
||||
controlslist="nodownload"
|
||||
crossorigin="anonymous"
|
||||
poster="/static/element.png"
|
||||
preload="none"
|
||||
src="http://localhost:63315/static/videoBodyDemo.webm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
File body slot
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* 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 {
|
||||
VideoBodyView,
|
||||
VideoBodyViewState,
|
||||
type VideoBodyViewActions,
|
||||
type VideoBodyViewModel,
|
||||
type VideoBodyViewSnapshot,
|
||||
} from "./VideoBodyView";
|
||||
Binary file not shown.
Reference in New Issue
Block a user