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-moved-card-still.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[internal] A toast's card no longer runs a hidden `transform` transition, with its `transitionrun` and `transitionend` events, when a Dialog, a modal Drawer or a scrim sheet opens or closes over it. The toast already stayed in place.

@vjeux
14 changes: 0 additions & 14 deletions packages/core/src/Toast/Toast.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,6 @@ 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
15 changes: 13 additions & 2 deletions packages/core/src/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,13 @@ const styles = stylex.create({
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionTimingFunction: easeVars['--ease-standard'],
},
// A card enters from its edge only when it first renders. A move between
// modal outlets renders it again, and it must not enter again.
entering: {
'@starting-style': {
opacity: 'var(--_toast-start-opacity, 0)',
transform: `translateY(var(--_toast-start-slide, var(--_toast-slide-y, ${TOAST_EDGE_DRIFT})))`,
opacity: 0,
transform: `translateY(var(--_toast-slide-y, ${TOAST_EDGE_DRIFT}))`,
},
},
variantDefault: {
Expand Down Expand Up @@ -164,6 +168,11 @@ interface ToastSurfaceProps extends ToastProps {
* live region of its own, so an update in place is announced once.
*/
isLiveRegion?: boolean;
/**
* True once the toast's row has started to open. The card then has no
* starting style, so a move between modal outlets starts no transition.
*/
hasEntryStarted?: boolean;
}

/**
Expand Down Expand Up @@ -205,6 +214,7 @@ export function ToastSurface({
gestureDirection,
timerKey,
isLiveRegion = true,
hasEntryStarted = false,
}: ToastSurfaceProps) {
const t = useComponentTranslator();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -400,6 +410,7 @@ export function ToastSurface({
themeProps('toast', {type}),
stylex.props(
styles.root,
!hasEntryStarted && styles.entering,
isError ? styles.variantError : styles.variantDefault,
isExiting && styles.exiting,
),
Expand Down
12 changes: 3 additions & 9 deletions packages/core/src/Toast/ToastViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,6 @@ 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 @@ -289,8 +283,8 @@ 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.
// Each move between modal outlets renders the row and its card again. Their
// starting styles 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) => {
Expand Down Expand Up @@ -351,7 +345,6 @@ function ToastRow({
{...stylex.props(
styles.toastWrapperInner,
isSettled && !isExiting && styles.toastWrapperInnerSettled,
hasEntryStarted && styles.toastWrapperInnerEntered,
)}>
<ToastSurface
type={type}
Expand All @@ -365,6 +358,7 @@ function ToastRow({
renderContent={o.renderContent}
timerKey={entry.id}
isLiveRegion={isLiveRegion}
hasEntryStarted={hasEntryStarted}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

/**
* @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
* @input The AppToastOverDialog and AppToastOverDrawer stories, recorded
* frame by frame
* @output Tests that a toast plays its entrance once. A Dialog or a modal
* Drawer that opens or closes over it moves the viewport. The toast does not
* enter again: its row and card start no transition
* @position Testing; validates ToastViewport.tsx and Toast.tsx in a real
* browser
*/
Expand All @@ -19,6 +21,7 @@ import {
const storybook = withStorybook(test);

const STORY = 'core-toast--app-toast-over-dialog';
const DRAWER_STORY = 'core-toast--app-toast-over-drawer';
const MESSAGE = 'Toast from inside the dialog!';

/** The toast's card on one frame. */
Expand All @@ -34,11 +37,17 @@ interface Recording {
frames: Frame[];
/** The opacity transitions the card ran: one for each entrance. */
entrances: number;
/**
* Each CSS transition or animation that started on the toast's row or card,
* as `part property`: `card transform`.
*/
starts: string[];
}

interface Recorder {
frames: Frame[];
transitions: Set<Animation>;
starts: string[];
isRecording: boolean;
}

Expand All @@ -48,10 +57,34 @@ async function startRecording(page: Page): Promise<void> {
const recorder: Recorder = {
frames: [],
transitions: new Set(),
starts: [],
isRecording: true,
};
(window as unknown as {__toastRecorder: Recorder}).__toastRecorder =
recorder;
// A move that renders the toast again from its starting style starts a
// transition, even toward the values it already shows.
const onStart = (event: Event) => {
const {target} = event;
if (!recorder.isRecording || !(target instanceof Element)) {
return;
}
const part = target.matches('[data-toast-id]')
? 'row'
: target.matches('[data-toast-id] .astryx-toast')
? 'card'
: null;
if (part == null) {
return;
}
const property =
event instanceof TransitionEvent
? event.propertyName
: (event as AnimationEvent).animationName;
recorder.starts.push(`${part} ${property}`);
};
document.addEventListener('transitionrun', onStart, true);
document.addEventListener('animationstart', onStart, true);
const record = () => {
const card = document.querySelector('[data-toast-id] .astryx-toast');
if (card != null) {
Expand Down Expand Up @@ -84,6 +117,7 @@ async function stopRecording(page: Page): Promise<Recording> {
return {
frames: recorder.frames,
entrances: recorder.transitions.size,
starts: recorder.starts,
};
});
}
Expand Down Expand Up @@ -190,13 +224,17 @@ test.describe('a toast in a top-layer viewport', () => {
await dialog.getByRole('button', {name: 'Show toast'}).click();
await expect(toastText(page)).toBeVisible();
await waitForRest(toastRow(page));
const {frames, entrances} = await stopRecording(page);
const {frames, entrances, starts} = 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);
// The start recorder sees an entrance, so its empty lists below count.
expect(starts).toEqual(
expect.arrayContaining(['row grid-template-rows', 'card opacity']),
);
});

test('a toast moved during its entrance finishes it once and settles', async ({
Expand Down Expand Up @@ -236,11 +274,12 @@ test.describe('a toast in a top-layer viewport', () => {
await dialog.getByRole('button', {name: 'Close'}).click();
await expect(dialog).toBeHidden();
await waitForRest(toastRow(page));
const {frames, entrances} = await stopRecording(page);
const {frames, entrances, starts} = await stopRecording(page);
await expectRowShown(page);
expect(placesOf(frames)).toEqual(['dialog', 'page']);
expect(Math.min(...opacitiesOf(frames))).toBeGreaterThan(0.99);
expect(entrances).toBe(0);
expect(starts).toEqual([]);
});

test('a toast already shown stays still when a Dialog opens over it', async ({
Expand All @@ -256,11 +295,41 @@ test.describe('a toast in a top-layer viewport', () => {
await expect.poll(async () => frameCount(page)).toBeGreaterThan(0);
await openDialog(page);
await waitForRest(toastRow(page));
const {frames, entrances} = await stopRecording(page);
const {frames, entrances, starts} = 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);
expect(starts).toEqual([]);
});

test('a toast already shown stays still when a modal Drawer closes over it and opens again', async ({
page,
}) => {
await openStory(page, storybook(), DRAWER_STORY);
await page
.getByRole('button', {name: 'Open candidate', exact: true})
.click();
const drawer = page.getByRole('dialog', {name: 'Candidate'});
await waitForRest(drawer);
await drawer.getByRole('button', {name: 'Move to Interview'}).click();
await waitForRest(toastRow(page));
await startRecording(page);
await expect.poll(async () => framesInDialog(page)).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await expect(drawer).toBeHidden();
await waitForRest(toastRow(page));
await page
.getByRole('button', {name: 'Open candidate', exact: true})
.click();
await waitForRest(drawer);
await waitForRest(toastRow(page));
const {frames, entrances, starts} = await stopRecording(page);
await expectRowShown(page);
expect(placesOf(frames)).toEqual(['dialog', 'page', 'dialog']);
expect(Math.min(...opacitiesOf(frames))).toBeGreaterThan(0.99);
expect(entrances).toBe(0);
expect(starts).toEqual([]);
});
});
2 changes: 0 additions & 2 deletions packages/core/src/theme/derivedVarRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,6 @@ 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