Repository navigation
fix(Toast): a toast plays its entrance once #10086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [fix] A toast plays its entrance once. When a `Dialog`, a modal `Drawer` or a bottom sheet with a scrim opens or closes while toasts show, the viewport moves into it or back out, and a toast already on screen now stays where it is instead of sliding in again. | ||
|
|
||
| @vjeux |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
266 changes: 266 additions & 0 deletions
266
packages/core/src/Toast/__tests__/ToastViewport.hostMove.interaction.browser.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file ToastViewport.hostMove.interaction.browser.spec.ts | ||
| * @input The AppToastOverDialog story, recorded frame by frame | ||
| * @output Tests that a toast plays its entrance once. A Dialog that opens or | ||
| * closes over it moves the viewport, and the toast does not enter again | ||
| * @position Testing; validates ToastViewport.tsx and Toast.tsx in a real | ||
| * browser | ||
| */ | ||
|
|
||
| import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; | ||
| import { | ||
| openStory, | ||
| waitForRest, | ||
| withStorybook, | ||
| } from '@astryxdesign/interaction-harness'; | ||
|
|
||
| const storybook = withStorybook(test); | ||
|
|
||
| const STORY = 'core-toast--app-toast-over-dialog'; | ||
| const MESSAGE = 'Toast from inside the dialog!'; | ||
|
|
||
| /** The toast's card on one frame. */ | ||
| interface Frame { | ||
| opacity: number; | ||
| /** Whether the card was inside a dialog, the viewport's modal outlet. */ | ||
| isInDialog: boolean; | ||
| } | ||
|
|
||
| /** What the recorder saw of the toast's card. */ | ||
| interface Recording { | ||
| /** The card on each frame it was in the page. */ | ||
| frames: Frame[]; | ||
| /** The opacity transitions the card ran: one for each entrance. */ | ||
| entrances: number; | ||
| } | ||
|
|
||
| interface Recorder { | ||
| frames: Frame[]; | ||
| transitions: Set<Animation>; | ||
| isRecording: boolean; | ||
| } | ||
|
|
||
| /** Records the toast card's opacity and its opacity transitions on each frame. */ | ||
| async function startRecording(page: Page): Promise<void> { | ||
| await page.evaluate(() => { | ||
| const recorder: Recorder = { | ||
| frames: [], | ||
| transitions: new Set(), | ||
| isRecording: true, | ||
| }; | ||
| (window as unknown as {__toastRecorder: Recorder}).__toastRecorder = | ||
| recorder; | ||
| const record = () => { | ||
| const card = document.querySelector('[data-toast-id] .astryx-toast'); | ||
| if (card != null) { | ||
| recorder.frames.push({ | ||
| opacity: Number(getComputedStyle(card).opacity), | ||
| isInDialog: card.closest('dialog') != null, | ||
| }); | ||
| for (const animation of card.getAnimations()) { | ||
| if ( | ||
| animation instanceof CSSTransition && | ||
| animation.transitionProperty === 'opacity' | ||
| ) { | ||
| recorder.transitions.add(animation); | ||
| } | ||
| } | ||
| } | ||
| if (recorder.isRecording) { | ||
| requestAnimationFrame(record); | ||
| } | ||
| }; | ||
| requestAnimationFrame(record); | ||
| }); | ||
| } | ||
|
|
||
| async function stopRecording(page: Page): Promise<Recording> { | ||
| return page.evaluate(() => { | ||
| const recorder = (window as unknown as {__toastRecorder: Recorder}) | ||
| .__toastRecorder; | ||
| recorder.isRecording = false; | ||
| return { | ||
| frames: recorder.frames, | ||
| entrances: recorder.transitions.size, | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| /** How many frames the recorder has seen the card in a dialog so far. */ | ||
| async function framesInDialog(page: Page): Promise<number> { | ||
| return page.evaluate( | ||
| () => | ||
| ( | ||
| window as unknown as {__toastRecorder: Recorder} | ||
| ).__toastRecorder.frames.filter(frame => frame.isInDialog).length, | ||
| ); | ||
| } | ||
|
|
||
| /** How many frames the recorder has seen the card so far. */ | ||
| async function frameCount(page: Page): Promise<number> { | ||
| return page.evaluate( | ||
| () => | ||
| (window as unknown as {__toastRecorder: Recorder}).__toastRecorder.frames | ||
| .length, | ||
| ); | ||
| } | ||
|
|
||
| /** The card's places in frame order, each run once: `['dialog', 'page']`. */ | ||
| function placesOf(frames: Frame[]): string[] { | ||
| const places: string[] = []; | ||
| for (const {isInDialog} of frames) { | ||
| const place = isInDialog ? 'dialog' : 'page'; | ||
| if (places.at(-1) !== place) { | ||
| places.push(place); | ||
| } | ||
| } | ||
| return places; | ||
| } | ||
|
|
||
| /** Whether the card faded back out after it had shown: a second entrance. */ | ||
| function fadesBack(frames: Frame[]): boolean { | ||
| let hasShown = false; | ||
| for (const {opacity} of frames) { | ||
| if (opacity > 0.9) { | ||
| hasShown = true; | ||
| } else if (hasShown && opacity < 0.3) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| async function openDialog(page: Page) { | ||
| await page.getByRole('button', {name: 'Open dialog'}).click(); | ||
| const dialog = page.getByRole('dialog'); | ||
| await waitForRest(dialog); | ||
| return dialog; | ||
| } | ||
|
|
||
| const toastRow = (page: Page) => page.locator('[data-toast-id]'); | ||
|
|
||
| /** The message in the toast's row, not in the viewport's live region. */ | ||
| const toastText = (page: Page) => toastRow(page).getByText(MESSAGE); | ||
|
|
||
| /** Whether the toast row's entry transition runs. */ | ||
| async function isEntering(page: Page): Promise<boolean> { | ||
| return page.evaluate(() => { | ||
| const row = document.querySelector('[data-toast-id]'); | ||
| return ( | ||
| row != null && | ||
| row | ||
| .getAnimations() | ||
| .some( | ||
| animation => | ||
| animation instanceof CSSTransition && | ||
| animation.transitionProperty === 'grid-template-rows' && | ||
| animation.playState === 'running', | ||
| ) | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * The overflow of the row's clip. A settled row releases it, so the card's | ||
| * shadow paints. | ||
| */ | ||
| async function rowOverflow(page: Page): Promise<string> { | ||
| return page.evaluate(() => { | ||
| const clip = document.querySelector('[data-toast-id] > div'); | ||
| return clip == null ? '' : getComputedStyle(clip).overflow; | ||
| }); | ||
| } | ||
|
|
||
| /** The row is shown, with a height, after the move. */ | ||
| async function expectRowShown(page: Page) { | ||
| await expect(toastRow(page)).toBeVisible(); | ||
| const box = await toastRow(page).boundingBox(); | ||
| expect(box?.height ?? 0).toBeGreaterThan(0); | ||
| } | ||
|
|
||
| const opacitiesOf = (frames: Frame[]) => frames.map(frame => frame.opacity); | ||
|
|
||
| test.describe('a toast in a top-layer viewport', () => { | ||
| test('a new toast plays its entrance once', async ({page}) => { | ||
| await openStory(page, storybook(), STORY); | ||
| const dialog = await openDialog(page); | ||
| await startRecording(page); | ||
| await dialog.getByRole('button', {name: 'Show toast'}).click(); | ||
| await expect(toastText(page)).toBeVisible(); | ||
| await waitForRest(toastRow(page)); | ||
| const {frames, entrances} = await stopRecording(page); | ||
| await expectRowShown(page); | ||
| expect(placesOf(frames)).toEqual(['dialog']); | ||
| // It fades in from nearly clear to fully shown. | ||
| expect(Math.min(...opacitiesOf(frames))).toBeLessThan(0.5); | ||
| expect(Math.max(...opacitiesOf(frames))).toBeGreaterThan(0.99); | ||
| expect(entrances).toBe(1); | ||
| }); | ||
|
|
||
| test('a toast moved during its entrance finishes it once and settles', async ({ | ||
| page, | ||
| }) => { | ||
| await openStory(page, storybook(), STORY); | ||
| // A slow entrance, so the move lands inside it on any runner. | ||
| await page.addStyleTag({ | ||
| content: | ||
| '[data-toast-id], [data-toast-id] * { --duration-fast: 1500ms !important; }', | ||
| }); | ||
| const dialog = await openDialog(page); | ||
| await startRecording(page); | ||
| await dialog.getByRole('button', {name: 'Show toast'}).click(); | ||
| await expect.poll(async () => framesInDialog(page)).toBeGreaterThan(0); | ||
| expect(await isEntering(page)).toBe(true); | ||
| await dialog.getByRole('button', {name: 'Close'}).click(); | ||
| await expect(dialog).toBeHidden(); | ||
| await expect(toastText(page)).toBeVisible(); | ||
| await expect.poll(async () => rowOverflow(page)).toBe('visible'); | ||
| const {frames, entrances} = await stopRecording(page); | ||
| await expectRowShown(page); | ||
| expect(placesOf(frames)).toEqual(['dialog', 'page']); | ||
| expect(fadesBack(frames)).toBe(false); | ||
| expect(entrances).toBe(1); | ||
| }); | ||
|
|
||
| test('a toast already shown stays still when the Dialog that hosts it closes', async ({ | ||
| page, | ||
| }) => { | ||
| await openStory(page, storybook(), STORY); | ||
| const dialog = await openDialog(page); | ||
| await dialog.getByRole('button', {name: 'Show toast'}).click(); | ||
| await waitForRest(toastRow(page)); | ||
| await startRecording(page); | ||
| await expect.poll(async () => framesInDialog(page)).toBeGreaterThan(0); | ||
| await dialog.getByRole('button', {name: 'Close'}).click(); | ||
| await expect(dialog).toBeHidden(); | ||
| await waitForRest(toastRow(page)); | ||
| const {frames, entrances} = await stopRecording(page); | ||
| await expectRowShown(page); | ||
| expect(placesOf(frames)).toEqual(['dialog', 'page']); | ||
| expect(Math.min(...opacitiesOf(frames))).toBeGreaterThan(0.99); | ||
| expect(entrances).toBe(0); | ||
| }); | ||
|
|
||
| test('a toast already shown stays still when a Dialog opens over it', async ({ | ||
| page, | ||
| }) => { | ||
| await openStory(page, storybook(), STORY); | ||
| const dialog = await openDialog(page); | ||
| await dialog.getByRole('button', {name: 'Show toast'}).click(); | ||
| await dialog.getByRole('button', {name: 'Close'}).click(); | ||
| await expect(dialog).toBeHidden(); | ||
| await waitForRest(toastRow(page)); | ||
| await startRecording(page); | ||
| await expect.poll(async () => frameCount(page)).toBeGreaterThan(0); | ||
| await openDialog(page); | ||
| await waitForRest(toastRow(page)); | ||
| const {frames, entrances} = await stopRecording(page); | ||
| await expectRowShown(page); | ||
| // The toast moved into the dialog that opened. | ||
| expect(placesOf(frames)).toEqual(['page', 'dialog']); | ||
| expect(Math.min(...opacitiesOf(frames))).toBeGreaterThan(0.99); | ||
| expect(entrances).toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Miss: once this drops the starting style, a host move during the entrance cancels the row's
grid-template-rowstransition (transitioncancel) and starts none, soonTransitionEndnever setsisSettledand the inner wrapper keepsoverflow: hidden: the card's shadow stays cut off until the toast leaves. If the local approach stays, settling the row on that cancel would cover it, for example insidewatchEntry:The "enters once" and "…when a Dialog opens over it" cases both move the row during its entrance, so a check that the row settles there (for example the inner wrapper's computed
overflow) would catch this.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed at 38b1799: the row settles on its own
transitioncancelforgrid-template-rows. The move-during-entrance case now slows the entrance and checks that the clip turns visible; it fails without the fix in both engines.