mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-27 13:47:33 -05:00
[MM-70198] Fix post preview layout shift by overlaying the "Show more" control (#37974)
* [MM-70198] Overlay permalink preview "Show more" to avoid layout shift Post permalink previews clip tall content and reveal a "Show more" affordance only after the body is measured on mount. The ellipsis-style button was rendered in normal flow below the clipped text, so when it flashed in the preview grew taller and pushed following content down. Position the ellipsis "Show more" button absolutely over the faded bottom of the preview so it is revealed without changing the preview's height. Co-authored-by: mattermost-code <matty-code@mattermost.com> * [MM-70198] Satisfy stylelint property order Co-authored-by: mattermost-code <matty-code@mattermost.com> * Change fade out to use a mask-image so colours match * Change new post preview fade out to fully fade before toggle text * Add E2E test for post preview Show more layout shift Verify that the overflowing permalink preview 'Show more' control is overlaid on the preview and does not change the post height when it is revealed after mount. * Move new E2E test to match format of others * Address PR feedback: extend permalink preview fade transparent stop to 24px matthewbirtch suggested moving the mask-image fully-transparent stop from calc(100% - 18px) to calc(100% - 24px). Applied to both the -webkit-mask-image and mask-image declarations to keep them consistent. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: mattermost-code <matty-code@mattermost.com> Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
This commit is contained in:
co-authored by
Cursor Agent
mattermost-code
Harrison Healey
parent
bcc9ce5e4a
commit
c36f979edd
@@ -6,8 +6,9 @@ import type {ServerChannel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {Disposable, Locator, Page} from '@playwright/test';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {expect, setupFileServer, test, watchElementSize} from '@mattermost/playwright-lib';
|
||||
import {expect, setupFileServer, test, testConfig, watchElementSize} from '@mattermost/playwright-lib';
|
||||
import type {ChannelsPage, ChannelsPost, PlaywrightClient4} from '@mattermost/playwright-lib';
|
||||
|
||||
test.describe('Post height', () => {
|
||||
@@ -54,10 +55,8 @@ test.describe('Post height', () => {
|
||||
|
||||
type PostHeightTestCase = {
|
||||
name: string;
|
||||
/** Static seed options for posts that don't depend on the file server URL. */
|
||||
seedOptions?: SeedOptions;
|
||||
/** Seed options builder for posts that reference the file server (e.g. Markdown images). */
|
||||
getSeedOptions?: (baseUrl: string) => SeedOptions;
|
||||
/** Returns the post to be measured and does any other prep work needed to set up the post. */
|
||||
makePost: (options: {fileServerUrl: string; siteUrl: string}) => Promise<Post>;
|
||||
/** Extra assertions to run once the post has loaded. */
|
||||
additionalCheck?: (args: {postComponent: ChannelsPost}) => Promise<void>;
|
||||
/** Playwright project names for which this test case should be skipped. */
|
||||
@@ -67,16 +66,18 @@ test.describe('Post height', () => {
|
||||
const testCases: PostHeightTestCase[] = [
|
||||
{
|
||||
name: 'text only post',
|
||||
seedOptions: {
|
||||
message: 'text only post',
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'text only post',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'post with replies',
|
||||
seedOptions: {
|
||||
message: 'post with replies',
|
||||
replyCount: 3,
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with replies',
|
||||
replyCount: 3,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the thread footer has rendered
|
||||
const image = postComponent.container.locator('.ThreadFooter');
|
||||
@@ -85,10 +86,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with reactions',
|
||||
seedOptions: {
|
||||
message: 'post with reactions',
|
||||
reactions: ['thumbsup', 'heart', 'tada'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with reactions',
|
||||
reactions: ['thumbsup', 'heart', 'tada'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the reactions have rendered
|
||||
const image = postComponent.container.locator('.Reaction');
|
||||
@@ -97,10 +99,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a single image',
|
||||
seedOptions: {
|
||||
message: 'post with a single image',
|
||||
files: ['mattermost.png'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single image',
|
||||
files: ['mattermost.png'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -109,10 +112,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a single small image',
|
||||
seedOptions: {
|
||||
message: 'post with a single small image',
|
||||
files: ['small-image.png'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single small image',
|
||||
files: ['small-image.png'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image has rendered
|
||||
const image = postComponent.container.locator('.small-image__container');
|
||||
@@ -123,10 +127,11 @@ test.describe('Post height', () => {
|
||||
name: 'post with a single large image',
|
||||
// MM-69979 Skip this on iPad because images that are too wide but above the minimum height cause layout shift
|
||||
skipProjects: ['ipad'],
|
||||
seedOptions: {
|
||||
message: 'post with a single large image',
|
||||
files: ['huge-image.jpg'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single large image',
|
||||
files: ['huge-image.jpg'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -135,10 +140,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a single wide image',
|
||||
seedOptions: {
|
||||
message: 'post with a single wide image',
|
||||
files: ['image-400x40.jpg'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single wide image',
|
||||
files: ['image-400x40.jpg'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image has rendered
|
||||
const image = postComponent.container.locator('.small-image__container img');
|
||||
@@ -147,10 +153,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a single tall image',
|
||||
seedOptions: {
|
||||
message: 'post with a single tall image',
|
||||
files: ['image-40x400.jpg'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single tall image',
|
||||
files: ['image-40x400.jpg'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image has rendered
|
||||
const image = postComponent.container.locator('.small-image__container img');
|
||||
@@ -159,10 +166,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a non-image file attachment',
|
||||
seedOptions: {
|
||||
message: 'post with a non-image file attachment',
|
||||
files: ['sample_text_file.txt'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a non-image file attachment',
|
||||
files: ['sample_text_file.txt'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the attachment has rendered
|
||||
const image = postComponent.container.locator('.post-image__columns');
|
||||
@@ -171,10 +179,11 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with multiple images',
|
||||
seedOptions: {
|
||||
message: 'post with multiple images',
|
||||
files: ['mattermost.png', 'mattermost-icon_128x128.png', 'mattermost.png'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with multiple images',
|
||||
files: ['mattermost.png', 'mattermost-icon_128x128.png', 'mattermost.png'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the images have rendered
|
||||
const image = postComponent.container.locator('.MediaGallery__tile img');
|
||||
@@ -183,38 +192,42 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a code block without a language',
|
||||
seedOptions: {
|
||||
message: '```\nconst foo = 1;\nconst bar = 2;\n```',
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: '```\nconst foo = 1;\nconst bar = 2;\n```',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'post with a syntax-highlighted code block',
|
||||
seedOptions: {
|
||||
message: '```javascript\nconst foo = 1;\nconst bar = 2;\n```',
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: '```javascript\nconst foo = 1;\nconst bar = 2;\n```',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'post with a message attachment',
|
||||
seedOptions: {
|
||||
message: 'post with a message attachment',
|
||||
props: {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'Author',
|
||||
title: 'Message attachment title',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Message attachment body text',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a message attachment',
|
||||
props: {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'Author',
|
||||
title: 'Message attachment title',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Message attachment body text',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'post with a single SVG attachment',
|
||||
seedOptions: {
|
||||
message: 'post with a single SVG attachment',
|
||||
files: ['icon.svg'],
|
||||
},
|
||||
makePost: () =>
|
||||
seedPost({
|
||||
message: 'post with a single SVG attachment',
|
||||
files: ['icon.svg'],
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the SVG has rendered as an image
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -223,9 +236,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a Markdown image',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -234,9 +248,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a small Markdown image',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -247,9 +262,10 @@ test.describe('Post height', () => {
|
||||
name: 'post with a large Markdown image',
|
||||
// MM-69979 Images that are too wide but above the minimum height cause layout shift
|
||||
skipProjects: ['chrome', 'firefox', 'ipad'],
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -258,9 +274,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a wide Markdown image',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -269,9 +286,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a tall Markdown image',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -282,9 +300,10 @@ test.describe('Post height', () => {
|
||||
name: 'post with an SVG Markdown image',
|
||||
// Either Chrome preloads the SVG's dimensions early or Firefox doesn't allocate the height properly
|
||||
skipProjects: ['firefox'],
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: ``,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: ``,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the Markdown image has rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -293,9 +312,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with an image preview',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/mattermost.png`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/mattermost.png`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image is rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -304,9 +324,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a small image preview',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/small-image.png`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/small-image.png`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image is rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -317,9 +338,10 @@ test.describe('Post height', () => {
|
||||
name: 'post with a large image preview',
|
||||
// MM-69979 Images that are too wide but above the minimum height cause layout shift
|
||||
skipProjects: ['chrome', 'firefox', 'ipad'],
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/huge-image.jpg`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/huge-image.jpg`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image is rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -328,9 +350,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a wide image preview',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/image-400x40.jpg`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/image-400x40.jpg`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image is rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -339,9 +362,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with a tall image preview',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/image-40x400.jpg`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/image-40x400.jpg`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the image is rendered
|
||||
const image = postComponent.container.locator('.image-loaded-container');
|
||||
@@ -350,9 +374,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with an OpenGraph preview',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/opengraph.html`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/opengraph.html`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that an OpenGraph preview was rendered
|
||||
const preview = postComponent.container.locator('.PostAttachmentOpenGraph');
|
||||
@@ -367,9 +392,10 @@ test.describe('Post height', () => {
|
||||
},
|
||||
{
|
||||
name: 'post with an OpenGraph preview with a larger image',
|
||||
getSeedOptions: (baseUrl) => ({
|
||||
message: `${baseUrl}/opengraph-huge.html`,
|
||||
}),
|
||||
makePost: ({fileServerUrl}) =>
|
||||
seedPost({
|
||||
message: `${fileServerUrl}/opengraph-huge.html`,
|
||||
}),
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
const preview = postComponent.container.locator('.PostAttachmentOpenGraph');
|
||||
await expect(preview).toBeVisible();
|
||||
@@ -379,6 +405,36 @@ test.describe('Post height', () => {
|
||||
await expect(preview.locator('.PostAttachmentOpenGraph__image img')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'post with a post preview',
|
||||
makePost: async ({siteUrl}) => {
|
||||
const linkedPost = await seedPost({
|
||||
message: 'This is a post to be previewed.',
|
||||
});
|
||||
|
||||
return seedPost({
|
||||
message: `${siteUrl}/${team.name}/pl/${linkedPost.id}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'post with a long post preview',
|
||||
makePost: async ({siteUrl}) => {
|
||||
const linkedPost = await seedPost({
|
||||
message: new Array(50).fill('This is a multi-line post to be previewed.').join('\n'),
|
||||
});
|
||||
|
||||
return seedPost({
|
||||
message: `${siteUrl}/${team.name}/pl/${linkedPost.id}`,
|
||||
});
|
||||
},
|
||||
additionalCheck: async ({postComponent}) => {
|
||||
// * Verify that the preview is faded out and has the "Show more" link visible
|
||||
const showMoreButton = postComponent.container.locator('.post-preview-collapse__show-more-button');
|
||||
await expect(showMoreButton).toBeVisible();
|
||||
await expect(postComponent.container.locator('.post-message-preview--overflow')).toBeVisible();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
@@ -391,10 +447,10 @@ test.describe('Post height', () => {
|
||||
`Not supported on ${testInfo.project.name}`,
|
||||
);
|
||||
|
||||
const seedOptions = testCase.getSeedOptions
|
||||
? testCase.getSeedOptions(fileServerUrl)
|
||||
: testCase.seedOptions!;
|
||||
const post = await seedPost(seedOptions);
|
||||
const post = await testCase.makePost({
|
||||
fileServerUrl,
|
||||
siteUrl: testConfig.internalBaseURL,
|
||||
});
|
||||
|
||||
const {sizeWatcher, postComponent} = await openChannelAndGetPost(post.id);
|
||||
|
||||
|
||||
@@ -1965,25 +1965,14 @@
|
||||
// with a bottom fade so truncated content reads as "more below".
|
||||
.post-message--collapsed.post-message-preview--overflow {
|
||||
.post-message__text-container {
|
||||
position: relative;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 82px), transparent calc(100% - 24px));
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 82px), transparent calc(100% - 24px));
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 40%;
|
||||
min-height: 28px;
|
||||
max-height: 64px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--center-channel-bg-rgb), 0) 0%,
|
||||
var(--center-channel-bg) 100%
|
||||
);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.post-preview-collapse__show-more-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user