Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/toast-entrance-once.md
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
14 changes: 14 additions & 0 deletions packages/core/src/Toast/Toast.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ export const docs = {
default: '1',
private: true,
},
{
name: '--_toast-start-opacity',
description:
'Private starting opacity of the entry; ToastViewport sets it to 1 once the entrance starts',
default: '0',
private: true,
},
{
name: '--_toast-start-slide',
description:
'Private starting offset of the entry; ToastViewport sets it to 0px once the entrance starts',
default: 'var(--_toast-slide-y)',
private: true,
},
],
},

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ const styles = stylex.create({
},
transitionTimingFunction: easeVars['--ease-standard'],
'@starting-style': {
opacity: 0,
transform: `translateY(var(--_toast-slide-y, ${TOAST_EDGE_DRIFT}))`,
opacity: 'var(--_toast-start-opacity, 0)',
transform: `translateY(var(--_toast-start-slide, var(--_toast-slide-y, ${TOAST_EDGE_DRIFT})))`,
},
},
variantDefault: {
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/Toast/ToastViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ const styles = stylex.create({
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionTimingFunction: easeVars['--ease-standard'],
},
// A row opens from a closed track only when it first renders. A move to
// another modal outlet renders it again, and it must not open again.
toastWrapperEntering: {
'@starting-style': {
gridTemplateRows: '0fr',
paddingBlockEnd: 0,
Expand Down Expand Up @@ -185,6 +189,12 @@ const styles = stylex.create({
toastWrapperInnerSettled: {
overflow: 'visible',
},
// The card's starting style reads these. Once the entrance starts, a move
// renders the card at rest.
toastWrapperInnerEntered: {
'--_toast-start-opacity': 1,
'--_toast-start-slide': '0px',
},
});

export interface ToastViewportProps {
Expand Down Expand Up @@ -263,12 +273,40 @@ function ToastRow({
const isAutoHide = o.isAutoHide ?? (type === 'error' ? false : true);
const dur = o.autoHideDuration ?? 5000;
const [isSettled, setIsSettled] = useState(false);
// Each move between modal outlets renders the row again. Its starting style
// must not apply then.
const [hasEntryStarted, setHasEntryStarted] = useState(false);
// React's transition run and cancel events have no propertyName field.
const watchEntry = useCallback((row: HTMLDivElement | null) => {
if (row == null) {
return;
}
const onTransition = (event: TransitionEvent) => {
if (event.target !== row || event.propertyName !== 'grid-template-rows') {
return;
}
setHasEntryStarted(true);
// A move cancels the entry, and the row then renders fully open. No
// transition ends, so the row settles here.
if (event.type === 'transitioncancel') {
setIsSettled(true);
}
};
row.addEventListener('transitionrun', onTransition);
row.addEventListener('transitioncancel', onTransition);
return () => {
row.removeEventListener('transitionrun', onTransition);
row.removeEventListener('transitioncancel', onTransition);
};
}, []);

return (
<div
ref={watchEntry}
data-toast-id={entry.id}
{...stylex.props(
styles.toastWrapper,
!hasEntryStarted && styles.toastWrapperEntering,

Copy link
Copy Markdown
Contributor

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-rows transition (transitioncancel) and starts none, so onTransitionEnd never sets isSettled and the inner wrapper keeps overflow: 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 inside watchEntry:

const onCancel = (event: TransitionEvent) => {
  if (event.target === row && event.propertyName === 'grid-template-rows') {
    // A move cancels the entry; the row then renders open with no transition.
    setIsSettled(true);
  }
};
row.addEventListener('transitioncancel', onCancel);

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.

Copy link
Copy Markdown
Contributor Author

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 transitioncancel for grid-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.

isReversed ? styles.toastWrapperFromTop : styles.toastWrapperFromBottom,
isReversed ? styles.toastWrapperGapReversed : styles.toastWrapperGap,
isExiting && styles.toastWrapperExiting,
Expand All @@ -291,11 +329,13 @@ function ToastRow({
// still owns pointer events and restores the clip synchronously when
// dismissal starts.
setIsSettled(true);
setHasEntryStarted(true);
}}>
<div
{...stylex.props(
styles.toastWrapperInner,
isSettled && !isExiting && styles.toastWrapperInnerSettled,
hasEntryStarted && styles.toastWrapperInnerEntered,
)}>
<ToastSurface
type={type}
Expand Down
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);
});
});
2 changes: 2 additions & 0 deletions packages/core/src/theme/derivedVarRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ const VARS_WITHOUT_DERIVED_MAPPING = new Set([
'--_toast-swipe-exit-y',
'--_toast-swipe-opacity',
'--_toast-swipe-scale',
'--_toast-start-opacity',
'--_toast-start-slide',
// Set by each ChatMessage article from its own content and read as its
// avatar's top margin, so the avatar lines up with a name row. Content
// state, not a value a theme author sets.
Expand Down
Loading