Skip to content
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

New events for Astro's view transition API #9090

Merged
merged 14 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 9 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
16 changes: 16 additions & 0 deletions .changeset/few-keys-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'astro': minor
---
Take full control over the behavior of view transitions!

Three new events now complement the existing `astro:after-swap` and `astro:page-load` events:

``` javascript
astro:before-preparation // Control how the DOM and other resources of the target page are loaded
astro:after-preparation // Last changes before taking off? Remove that loading indicator? Here you go!
astro:before-swap // Control how the DOM is updated to match the new page
```

The `astro:before-*` events allow you to change properties and strategies of the view transition implementation.
The `astro:after-*` events allow you to make changes to the current DOM before and after the transition.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll note that saying it's for "before and after" is maybe confusing here, since we're trying to distinguish before- events from after- events?

The explanation on the docs PR refers to after events firing "when a particular phase has finished".

The distinction between these two here makes it sound like "before" is used for changing how a process occurs, but "after" sounds like it only changes WHAT gets written to the DOM. Is that correct? Are both of these correct? If so, maybe check that "tip" in the docs section for updating/nuance!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, 😄 that was really confusing, thanks for pointing it out! I like the wording in the "tip" much better. The after event is not about what is written to the DOM. Making changes to the DOM was just one example of what you can do when you know a phase is over.

Head over to [View Transition docs](https://docs.astro.build/en/guides/view-transitions/) to find out more about the events!
martrapp marked this conversation as resolved.
Show resolved Hide resolved
27 changes: 24 additions & 3 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,38 @@ declare module 'astro:transitions' {
type TransitionModule = typeof import('./dist/transitions/index.js');
export const slide: TransitionModule['slide'];
export const fade: TransitionModule['fade'];
export const createAnimationScope: TransitionModule['createAnimationScope'];

type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro');
export const ViewTransitions: ViewTransitionsModule['default'];
}

declare module 'astro:transitions/client' {
type TransitionRouterModule = typeof import('./dist/transitions/router.js');
export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions'];
export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage'];
export const navigate: TransitionRouterModule['navigate'];
export type Options = import('./dist/transitions/router.js').Options;

type TransitionUtilModule = typeof import('./dist/transitions/util.js');
export const supportsViewTransitions: TransitionUtilModule['supportsViewTransitions'];
export const getFallback: TransitionUtilModule['getFallback'];
export const transitionEnabledOnThisPage: TransitionUtilModule['transitionEnabledOnThisPage'];

export type Fallback = import('./dist/transitions/types.ts').Fallback;
export type Direction = import('./dist/transitions/types.ts').Direction;
export type NavigationTypeString = import('./dist/transitions/types.ts').NavigationTypeString;
export type Options = import('./dist/transitions/types.ts').Options;

type EventModule = typeof import('./dist/transitions/events.js');
export const TRANSITION_BEFORE_PREPARATION: EventModule['TRANSITION_BEFORE_PREPARATION'];
export const TRANSITION_AFTER_PREPARATION: EventModule['TRANSITION_AFTER_PREPARATION'];
export const TRANSITION_BEFORE_SWAP: EventModule['TRANSITION_BEFORE_SWAP'];
export const TRANSITION_AFTER_SWAP: EventModule['TRANSITION_AFTER_SWAP'];
export const TRANSITION_PAGE_LOAD: EventModule['TRANSITION_PAGE_LOAD'];
export type TransitionBeforePreparationEvent =
import('./dist/transitions/events.ts').TransitionBeforePreparationEvent;
export type TransitionBeforeSwapEvent =
import('./dist/transitions/events.ts').TransitionBeforeSwapEvent;
export const isTransitionBeforePreparationEvent: EventModule['isTransitionBeforePreparationEvent'];
export const isTransitionBeforeSwapEvent: EventModule['isTransitionBeforeSwapEvent'];
}

declare module 'astro:prefetch' {
Expand Down
6 changes: 4 additions & 2 deletions packages/astro/components/ViewTransitions.astro
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ const { fallback = 'animate', handleForms } = Astro.props;
import type { Options } from 'astro:transitions/client';
import { supportsViewTransitions, navigate } from 'astro:transitions/client';
// NOTE: import from `astro/prefetch` as `astro:prefetch` requires the `prefetch` config to be enabled
// @ts-ignore
martrapp marked this conversation as resolved.
Show resolved Hide resolved
import { init } from 'astro/prefetch';

export type Fallback = 'none' | 'animate' | 'swap';
type Fallback = 'none' | 'animate' | 'swap';

function getFallback(): Fallback {
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
Expand Down Expand Up @@ -77,6 +78,7 @@ const { fallback = 'animate', handleForms } = Astro.props;
ev.preventDefault();
navigate(link.href, {
history: link.dataset.astroHistory === 'replace' ? 'replace' : 'auto',
sourceElement: link,
});
});

Expand All @@ -94,7 +96,7 @@ const { fallback = 'animate', handleForms } = Astro.props;
let action = submitter?.getAttribute('formaction') ?? form.action ?? location.pathname;
const method = submitter?.getAttribute('formmethod') ?? form.method;

const options: Options = {};
const options: Options = { sourceElement: submitter ?? form };
martrapp marked this conversation as resolved.
Show resolved Hide resolved
if (method === 'get') {
const params = new URLSearchParams(formData as any);
const url = new URL(action);
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@
"default": "./dist/core/middleware/namespace.js"
},
"./transitions": "./dist/transitions/index.js",
"./transitions/events": "./dist/transitions/events.js",
"./transitions/router": "./dist/transitions/router.js",
"./transitions/types": "./dist/transitions/types.js",
"./prefetch": "./dist/prefetch/index.js",
"./i18n": "./dist/i18n/index.js"
},
Expand Down
45 changes: 36 additions & 9 deletions packages/astro/src/runtime/server/transition.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {
SSRResult,
TransitionAnimation,
TransitionAnimationPair,
TransitionAnimationValue,
TransitionDirectionalAnimations,
} from '../../@types/astro.js';
import { fade, slide } from '../../transitions/index.js';
import { markHTMLString } from './escape.js';
Expand Down Expand Up @@ -34,6 +36,19 @@ const getAnimations = (name: TransitionAnimationValue) => {
if (typeof name === 'object') return name;
};

const addPairs = (
animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>,
stylesheet: ViewTransitionStyleSheet
) => {
for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
for (const [image, rules] of Object.entries(images) as Entries<
(typeof animations)[typeof direction]
>) {
stylesheet.addAnimationPair(direction, image, rules);
}
}
};

export function renderTransition(
result: SSRResult,
hash: string,
Expand All @@ -48,13 +63,7 @@ export function renderTransition(

const animations = getAnimations(animationName);
if (animations) {
for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
for (const [image, rules] of Object.entries(images) as Entries<
(typeof animations)[typeof direction]
>) {
sheet.addAnimationPair(direction, image, rules);
}
}
addPairs(animations, sheet);
} else if (animationName === 'none') {
sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;');
sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;');
Expand All @@ -65,6 +74,19 @@ export function renderTransition(
return scope;
}

export function createAnimationScope(
transitionName: string,
animations: Record<string, TransitionAnimationPair>
) {
const hash = Math.random().toString(36).slice(2, 8);
const scope = `astro-${hash}`;
martrapp marked this conversation as resolved.
Show resolved Hide resolved
const sheet = new ViewTransitionStyleSheet(scope, transitionName);

addPairs(animations, sheet);

return { scope, styles: sheet.toString().replaceAll('"', '') };
}

class ViewTransitionStyleSheet {
private modern: string[] = [];
private fallback: string[] = [];
Expand Down Expand Up @@ -113,13 +135,18 @@ class ViewTransitionStyleSheet {
}

addAnimationPair(
direction: 'forwards' | 'backwards',
direction: 'forwards' | 'backwards' | string,
image: 'old' | 'new',
rules: TransitionAnimation | TransitionAnimation[]
) {
const { scope, name } = this;
const animation = stringifyAnimation(rules);
const prefix = direction === 'backwards' ? `[data-astro-transition=back]` : '';
const prefix =
direction === 'backwards'
? `[data-astro-transition=back]`
: direction === 'forwards'
? ''
: `[data-astro-transition=${direction}]`;
this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
this.addRule(
'fallback',
Expand Down
184 changes: 184 additions & 0 deletions packages/astro/src/transitions/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { updateScrollPosition } from './router.js';
import type { Direction, NavigationTypeString } from './types.js';

export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation';
export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation';
export const TRANSITION_BEFORE_SWAP = 'astro:before-swap';
export const TRANSITION_AFTER_SWAP = 'astro:after-swap';
export const TRANSITION_PAGE_LOAD = 'astro:page-load';

type Events =
| typeof TRANSITION_AFTER_PREPARATION
| typeof TRANSITION_AFTER_SWAP
| typeof TRANSITION_PAGE_LOAD;
export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD);

/*
* Common stuff
*/
class BeforeEvent extends Event {
readonly from: URL;
to: URL;
direction: Direction | string;
readonly navigationType: NavigationTypeString;
readonly sourceElement: Element | undefined;
readonly info: any;
newDocument: Document;

constructor(
type: string,
eventInitDict: EventInit | undefined,
from: URL,
to: URL,
direction: Direction | string,
navigationType: NavigationTypeString,
sourceElement: Element | undefined,
info: any,
newDocument: Document
) {
super(type, eventInitDict);
this.from = from;
this.to = to;
this.direction = direction;
this.navigationType = navigationType;
this.sourceElement = sourceElement;
this.info = info;
this.newDocument = newDocument;

Object.defineProperties(this, {
from: { enumerable: true },
to: { enumerable: true, writable: true },
direction: { enumerable: true, writable: true },
navigationType: { enumerable: true },
sourceElement: { enumerable: true },
info: { enumerable: true },
newDocument: { enumerable: true, writable: true },
});
}
}

/*
* TransitionBeforePreparationEvent

*/
export const isTransitionBeforePreparationEvent = (
value: any
): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION;
export class TransitionBeforePreparationEvent extends BeforeEvent {
formData: FormData | undefined;
loader: () => Promise<void>;
constructor(
from: URL,
to: URL,
direction: Direction | string,
navigationType: NavigationTypeString,
sourceElement: Element | undefined,
info: any,
newDocument: Document,
formData: FormData | undefined,
loader: (event: TransitionBeforePreparationEvent) => Promise<void>
) {
super(
TRANSITION_BEFORE_PREPARATION,
{ cancelable: true },
from,
to,
direction,
navigationType,
sourceElement,
info,
newDocument
);
this.formData = formData;
this.loader = loader.bind(this, this);
Object.defineProperties(this, {
formData: { enumerable: true },
loader: { enumerable: true, writable: true },
});
}
}

/*
* TransitionBeforeSwapEvent
*/

export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent =>
value.type === TRANSITION_BEFORE_SWAP;
export class TransitionBeforeSwapEvent extends BeforeEvent {
readonly direction: Direction | string;
readonly viewTransition: ViewTransition;
swap: () => void;

constructor(
afterPreparation: BeforeEvent,
viewTransition: ViewTransition,
swap: (event: TransitionBeforeSwapEvent) => void
) {
super(
TRANSITION_BEFORE_SWAP,
undefined,
afterPreparation.from,
afterPreparation.to,
afterPreparation.direction,
afterPreparation.navigationType,
afterPreparation.sourceElement,
afterPreparation.info,
afterPreparation.newDocument
);
this.direction = afterPreparation.direction;
this.viewTransition = viewTransition;
this.swap = swap.bind(this, this);

Object.defineProperties(this, {
direction: { enumerable: true },
viewTransition: { enumerable: true },
swap: { enumerable: true, writable: true },
});
}
}

export async function doPreparation(
from: URL,
to: URL,
direction: Direction | string,
navigationType: NavigationTypeString,
sourceElement: Element | undefined,
info: any,
formData: FormData | undefined,
defaultLoader: (event: TransitionBeforePreparationEvent) => Promise<void>
) {
const event = new TransitionBeforePreparationEvent(
from,
to,
direction,
navigationType,
sourceElement,
info,
window.document,
formData,
defaultLoader
);
if (document.dispatchEvent(event)) {
await event.loader();
if (!event.defaultPrevented) {
triggerEvent(TRANSITION_AFTER_PREPARATION);
if (event.navigationType !== 'traverse') {
// save the current scroll position before we change the DOM and transition to the new page
updateScrollPosition({ scrollX, scrollY });
}
}
}
return event;
}

export async function doSwap(
afterPreparation: BeforeEvent,
viewTransition: ViewTransition,
defaultSwap: (event: TransitionBeforeSwapEvent) => void
) {
const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition, defaultSwap);
document.dispatchEvent(event);
event.swap();
return event;
}
1 change: 1 addition & 0 deletions packages/astro/src/transitions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TransitionAnimationPair, TransitionDirectionalAnimations } from '../@types/astro.js';
export { createAnimationScope } from '../runtime/server/transition.js';

const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)';

Expand Down
Loading
Loading