0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

Initial View Transition Support (#7511)

* Basic support

* Add the fade transition

* Move CSS into a separate file

* Add transition name

* View Transitions changeset

* Replace the boolean transition with 'morph'

* Update to use `transition:animate`

* Use head propagation

* Move CSS into a separate file

* Add builtin animations and namespaced module

* Misquote

* Remove unused code

* Add automatic prefetching to the View Transitions router

* Use a data attribute for back nav animations

* Use [data-astro-transition]

* Add view transitions to examples

* Wait on the HTML response before calling startViewTransition

* Updated stuff

* Update the compiler

* Fix

* Fallback support

* Properly do fallback

* Simplify the selectors

* Put viewTransitions support behind a flag

* Upgrade the compiler

* Remove unused import

* Add tests

* Use an explicit import instead of types

* Fix case where the click comes from within nested content

* Fix linting

* Add a test for the back button

* Prevent glitch in fallback

* Do not combine selectors

* Fallback to MPA nav if there is an issue fetching

* Fallback swap if there are no animations

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/astro/components/ViewTransitions.astro

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update packages/astro/components/ViewTransitions.astro

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update the changeset

* PR review changes

* Update more based on review comments.

* Update the updateDOM default

* Pass in transitions options to the compiler

* Update broken tests

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* h2 -> h4

* Upgrade to stable compiler

* Remove exp redirects from sitemap

* Remove usage from examples

* Remove example updates

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
Matthew Phillips 2023-07-19 15:18:41 -04:00 committed by GitHub
parent eafe996b60
commit 6a12fcecb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 779 additions and 21 deletions

View file

@ -0,0 +1,62 @@
---
'astro': minor
---
Built-in View Transitions Support (experimental)
Astro now supports [view transitions](https://developer.chrome.com/docs/web-platform/view-transitions/) through the new `<ViewTransitions />` component and the `transition:animate` (and associated) directives. View transitions are a great fit for content-oriented sites, and we see it as the best path to get the benefits of client-side routing (smoother transitions) without sacrificing the more simple mental model of MPAs.
Enable support for view transitions in Astro 2.9 by adding the experimental flag to your config:
```js
import { defineConfig } from 'astro/config';
export default defineConfig({
experimental: {
viewTransitions: true,
},
})
```
This enables you to use the new APIs added.
#### <ViewTransitions />
This is a component which acts as the *router* for transitions between pages. Add it to the `<head>` section of each individual page where transitions should occur *in the client* as you navigate away to another page, instead of causing a full page browser refresh. To enable support throughout your entire app, add the component in some common layout or component that targets the `<head>` of every page.
__CommonHead.astro__
```astro
---
import { ViewTransitions } from 'astro:transitions';
---
<meta charset="utf-8">
<title>{Astro.props.title}</title>
<ViewTransitions />
```
With only this change, your app will now route completely in-client. You can then add transitions to individual elements using the `transition:animate` directive.
#### Animations
Add `transition:animate` to any element to use Astro's built-in animations.
```astro
<header transition:animate="slide">
```
In the above, Astro's `slide` animation will cause the `<header>` element to slide out to the left, and then slide in from the right when you navigate away from the page.
You can also customize these animations using any CSS animation properties, for example, by specifying a duration:
```astro
---
import { slide } from 'astro:transition';
---
<header transition:animate={slide({ duration: 200 })}>
```
#### Continue learning
Check out the [client-side routing docs](https://docs.astro.build/en/guides/client-side-routing/) to learn more.

View file

@ -1,4 +1,4 @@
---
---
interface Props {
title: string;
}

View file

@ -25,7 +25,6 @@ const {
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
rel="stylesheet"
/>
<script is:inline>
// This code is inlined in the head to make dark mode instant & blocking.
const getThemePreference = () => {

View file

@ -70,6 +70,15 @@ declare module 'astro:assets' {
export const { getImage, getConfiguredImageService, Image }: AstroAssets;
}
declare module 'astro:transitions' {
type TransitionModule = typeof import('./dist/transitions/index.js');
export const slide: TransitionModule['slide'];
export const fade: TransitionModule['fade'];
type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro');
export const ViewTransitions: ViewTransitionsModule['default'];
}
type MD = import('./dist/@types/astro').MarkdownInstance<Record<string, any>>;
interface ExportedMarkdownModuleEntities {
frontmatter: MD['frontmatter'];

View file

@ -0,0 +1,146 @@
---
type Fallback = 'none' | 'animate' | 'swap';
export interface Props {
fallback?: Fallback;
}
const { fallback = 'animate' } = Astro.props as Props;
---
<meta name="astro-view-transitions-enabled" content="true">
<meta name="astro-view-transitions-fallback" content={fallback}>
<script>
type Fallback = 'none' | 'animate' | 'swap';
type Direction = 'forward' | 'back';
// The History API does not tell you if navigation is forward or back, so
// you can figure it using an index. On pushState the index is incremented so you
// can use that to determine popstate if going forward or back.
let currentHistoryIndex = history.state?.index || 0;
if(!history.state) {
history.replaceState({index: currentHistoryIndex}, document.title);
}
const supportsViewTransitions = !!document.startViewTransition;
const transitionEnabledOnThisPage = () => !!document.querySelector('[name="astro-view-transitions-enabled"]');
async function getHTML(href: string) {
const res = await fetch(href)
const html = await res.text();
return { ok: res.ok, html };
}
function getFallback(): Fallback {
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
if(el) {
return el.getAttribute('content') as Fallback;
}
return 'animate';
}
const parser = new DOMParser();
async function updateDOM(dir: Direction, html: string, fallback?: Fallback) {
const doc = parser.parseFromString(html, 'text/html');
doc.documentElement.dataset.astroTransition = dir;
const swap = () => document.documentElement.replaceWith(doc.documentElement);
if(fallback === 'animate') {
let isAnimating = false;
addEventListener('animationstart', () => isAnimating = true, { once: true });
// Trigger the animations
document.documentElement.dataset.astroTransitionFallback = 'old';
doc.documentElement.dataset.astroTransitionFallback = 'new';
// If there are any animations, want for the animationend event.
addEventListener('animationend', swap, { once: true });
// If there are no animations, go ahead and swap on next tick
// This is necessary because we do not know if there are animations.
// The setTimeout is a fallback in case there are none.
setTimeout(() => !isAnimating && swap());
} else {
swap();
}
}
async function navigate(dir: Direction, href: string) {
let finished: Promise<void>;
const { html, ok } = await getHTML(href);
// If there is a problem fetching the new page, just do an MPA navigation to it.
if(!ok) {
location.href = href;
return;
}
if(supportsViewTransitions) {
finished = document.startViewTransition(() => updateDOM(dir, html)).finished;
} else {
finished = updateDOM(dir, html, getFallback());
}
try {
await finished;
} finally {
document.documentElement.removeAttribute('data-astro-transition');
}
}
// Prefetching
function maybePrefetch(pathname: string) {
if(document.querySelector(`link[rel=prefetch][href="${pathname}"]`)) return;
if(navigator.connection){
let conn = navigator.connection;
if(conn.saveData || /(2|3)g/.test(conn.effectiveType || '')) return;
}
let link = document.createElement('link');
link.setAttribute('rel', 'prefetch');
link.setAttribute('href', pathname);
document.head.append(link);
}
if(supportsViewTransitions || getFallback() !== 'none') {
document.addEventListener('click', (ev) => {
let link = ev.target;
if(link instanceof Element && link.tagName !== 'A') {
link = link.closest('a');
}
// This check verifies that the click is happening on an anchor
// that is going to another page within the same origin. Basically it determines
// same-origin navigation, but omits special key combos for new tabs, etc.
if (link &&
link instanceof HTMLAnchorElement &&
link.href &&
(!link.target || link.target === '_self') &&
link.origin === location.origin &&
ev.button === 0 && // left clicks only
!ev.metaKey && // new tab (mac)
!ev.ctrlKey && // new tab (windows)
!ev.altKey && // download
!ev.shiftKey &&
!ev.defaultPrevented &&
transitionEnabledOnThisPage()
) {
ev.preventDefault();
navigate('forward', link.href);
currentHistoryIndex++;
history.pushState({index: currentHistoryIndex}, '', link.href);
}
});
window.addEventListener('popstate', () => {
if(!transitionEnabledOnThisPage()) return;
const nextIndex = history.state?.index ?? (currentHistoryIndex + 1);
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
navigate(direction, location.href);
currentHistoryIndex = nextIndex;
});
['mouseenter', 'touchstart', 'focus'].forEach(evName => {
document.addEventListener(evName, ev => {
if(ev.target instanceof HTMLAnchorElement) {
let el = ev.target;
if(el.origin === location.origin && el.pathname !== location.pathname && transitionEnabledOnThisPage()) {
maybePrefetch(el.pathname);
}
}
}, { passive: true, capture: true });
});
}
</script>

View file

@ -1,2 +1,3 @@
export { default as Code } from './Code.astro';
export { default as Debug } from './Debug.astro';
export { default as ViewTransitions } from './ViewTransitions.astro';

View file

@ -0,0 +1,32 @@
@keyframes astroFadeInOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes astroFadeIn {
from { opacity: 0; }
}
@keyframes astroFadeOut {
to { opacity: 0; }
}
@keyframes astroSlideFromRight {
from { transform: translateX(100%); }
}
@keyframes astroSlideFromLeft {
from { transform: translateX(-100%); }
}
@keyframes astroSlideToRight {
to { transform: translateX(100%); }
}
@keyframes astroSlideToLeft {
to { transform: translateX(-100%); }
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
experimental: {
viewTransitions: true,
},
});

View file

@ -0,0 +1,8 @@
{
"name": "@e2e/view-transitions",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,17 @@
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<title>Testing</title>
<ViewTransitions />
</head>
<body>
<header transition:animate="morph">
<h1>testing</h1>
</header>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>

View file

@ -0,0 +1,12 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="four">Page 4</p>
<a id="click-one" href="/one">
<div>
Nested
<span>go to 1</span>
</div>
</a>
</Layout>

View file

@ -0,0 +1,8 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="one">Page 1</p>
<a id="click-two" href="/two">go to 2</a>
<a id="click-three" href="/three">go to 3</a>
</Layout>

View file

@ -0,0 +1,11 @@
<html>
<head>
<title>Page 3</title>
</head>
<body>
<main>
<p id="three">Page 3</p>
<a id="click-two" href="/two">go to 2</a>
</main>
</body>
</html>

View file

@ -0,0 +1,6 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="two">Page 2</p>
</Layout>

View file

@ -0,0 +1,101 @@
import { expect } from '@playwright/test';
import { testFactory } from './test-utils.js';
const test = testFactory({ root: './fixtures/view-transitions/' });
let devServer;
test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterAll(async () => {
await devServer.stop();
});
test.describe('View Transitions', () => {
test('Moving from page 1 to page 2', async ({ page, astro }) => {
const loads = [];
page.addListener('load', p => {
loads.push(p.title());
});
// Go to page 1
await page.goto(astro.resolveUrl('/one'));
let p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
// go to page 2
await page.click('#click-two');
p = page.locator('#two');
await expect(p, 'should have content').toHaveText('Page 2');
expect(loads.length, 'There should only be 1 page load').toEqual(1);
});
test('Back button is captured', async ({ page, astro }) => {
const loads = [];
page.addListener('load', p => {
loads.push(p.title());
});
// Go to page 1
await page.goto(astro.resolveUrl('/one'));
let p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
// go to page 2
await page.click('#click-two');
p = page.locator('#two');
await expect(p, 'should have content').toHaveText('Page 2');
// Back to page 1
await page.goBack();
p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
expect(loads.length, 'There should only be 1 page load').toEqual(1);
});
test('Clicking on a link with nested content', async ({ page, astro }) => {
const loads = [];
page.addListener('load', p => {
loads.push(p.title());
});
// Go to page 4
await page.goto(astro.resolveUrl('/four'));
let p = page.locator('#four');
await expect(p, 'should have content').toHaveText('Page 4');
// Go to page 1
await page.click('#click-one');
p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
expect(loads.length, 'There should only be 1 page load').toEqual(1);
});
test('Moving from a page without ViewTransitions triggers a full page navigation', async ({ page, astro }) => {
const loads = [];
page.addListener('load', p => {
loads.push(p.title());
});
// Go to page 1
await page.goto(astro.resolveUrl('/one'));
let p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
// Go to page 3 which does *not* have ViewTransitions enabled
await page.click('#click-three');
p = page.locator('#three');
await expect(p, 'should have content').toHaveText('Page 3');
await page.click('#click-two');
p = page.locator('#two');
await expect(p, 'should have content').toHaveText('Page 2');
expect(loads.length, 'There should be 2 page loads. The original, then going from 3 to 2').toEqual(2);
});
});

View file

@ -71,7 +71,8 @@
"./middleware": {
"types": "./dist/core/middleware/index.d.ts",
"default": "./dist/core/middleware/index.js"
}
},
"./transitions": "./dist/transitions/index.js"
},
"imports": {
"#astro/*": "./dist/*.js"
@ -114,7 +115,7 @@
"test:e2e:match": "playwright test -g"
},
"dependencies": {
"@astrojs/compiler": "^1.5.3",
"@astrojs/compiler": "^1.6.0",
"@astrojs/internal-helpers": "^0.1.1",
"@astrojs/language-server": "^1.0.0",
"@astrojs/markdown-remark": "^2.2.1",
@ -127,6 +128,7 @@
"@babel/traverse": "^7.22.5",
"@babel/types": "^7.22.5",
"@types/babel__core": "^7.20.1",
"@types/dom-view-transitions": "^1.0.1",
"@types/yargs-parser": "^21.0.0",
"acorn": "^8.9.0",
"boxen": "^6.2.1",
@ -150,6 +152,7 @@
"kleur": "^4.1.4",
"magic-string": "^0.27.0",
"mime": "^3.0.0",
"network-information-types": "^0.1.1",
"ora": "^6.3.1",
"p-limit": "^4.0.0",
"path-to-regexp": "^6.2.1",

View file

@ -55,6 +55,27 @@ export interface AstroBuiltinProps {
'client:only'?: boolean | string;
}
export interface TransitionAnimation {
name: string; // The name of the keyframe
delay?: number | string;
duration?: number | string;
easing?: string;
fillMode?: string;
direction?: string;
}
export interface TransitionAnimationPair {
old: TransitionAnimation | TransitionAnimation[];
new: TransitionAnimation | TransitionAnimation[];
}
export interface TransitionDirectionalAnimations {
forwards: TransitionAnimationPair;
backwards: TransitionAnimationPair;
}
export type TransitionAnimationValue = 'morph' | 'slide' | 'fade' | TransitionDirectionalAnimations;
// Allow users to extend this for astro-jsx.d.ts
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface AstroClientDirectives {}
@ -69,6 +90,8 @@ export interface AstroBuiltinAttributes {
'set:html'?: any;
'set:text'?: any;
'is:raw'?: boolean;
'transition:animate'?: 'morph' | 'slide' | 'fade' | TransitionDirectionalAnimations;
'transition:name'?: string;
}
export interface AstroDefineVarsAttribute {
@ -1227,6 +1250,27 @@ export interface AstroUserConfig {
* ```
*/
assets?: boolean;
/**
* @docs
* @name experimental.viewTransitions
* @type {boolean}
* @default `false`
* @version 2.9.0
* @description
* Enable experimental support for the `<ViewTransitions / >` component. With this enabled
* you can opt-in to [client-side routing](https://docs.astro.build/en/guides/client-side-routing/) on a per-page basis using this component
* and enable animations with the `transition:animate` directive.
*
* ```js
* {
* experimental: {
* viewTransitions: true,
* },
* }
* ```
*/
viewTransitions?: boolean;
};
// Legacy options to be removed

View file

@ -45,6 +45,8 @@ export async function compile({
astroGlobalArgs: JSON.stringify(astroConfig.site),
scopedStyleStrategy: astroConfig.scopedStyleStrategy,
resultScopedSlot: true,
experimentalTransitions: astroConfig.experimental.viewTransitions,
transitionsAnimationURL: 'astro/components/viewtransitions.css',
preprocessStyle: createStylePreprocessor({
filename,
viteConfig,

View file

@ -45,6 +45,7 @@ const ASTRO_CONFIG_DEFAULTS = {
redirects: {},
experimental: {
assets: false,
viewTransitions: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@ -232,6 +233,7 @@ export const AstroConfigSchema = z.object({
experimental: z
.object({
assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets),
viewTransitions: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.viewTransitions),
})
.passthrough()
.refine(

View file

@ -27,6 +27,7 @@ import astroScannerPlugin from '../vite-plugin-scanner/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js';
import astroTransitions from '../transitions/vite-plugin-transitions.js';
import { joinPaths } from './path.js';
interface CreateViteOptions {
@ -132,6 +133,7 @@ export async function createVite(
astroContentAssetPropagationPlugin({ mode, settings }),
vitePluginSSRManifest(),
settings.config.experimental.assets ? [astroAssetsPlugin({ settings, logging, mode })] : [],
astroTransitions({ config: settings.config }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),

View file

@ -7,7 +7,7 @@ function validateArgs(args: unknown[]): args is Parameters<AstroComponentFactory
if (!args[0] || typeof args[0] !== 'object') return false;
return true;
}
function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string): AstroComponentFactory {
function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string, propagation?: PropagationHint): AstroComponentFactory {
const name = moduleId?.split('/').pop()?.replace('.astro', '') ?? '';
const fn = (...args: Parameters<AstroComponentFactory>) => {
if (!validateArgs(args)) {
@ -22,6 +22,7 @@ function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string): Astr
// Add a flag to this callback to mark it as an Astro component
fn.isAstroComponentFactory = true;
fn.moduleId = moduleId;
fn.propagation = propagation;
return fn;
}
@ -32,17 +33,17 @@ interface CreateComponentOptions {
}
function createComponentWithOptions(opts: CreateComponentOptions) {
const cb = baseCreateComponent(opts.factory, opts.moduleId);
cb.propagation = opts.propagation;
const cb = baseCreateComponent(opts.factory, opts.moduleId, opts.propagation);
return cb;
}
// Used in creating the component. aka the main export.
export function createComponent(
arg1: AstroComponentFactory | CreateComponentOptions,
moduleId?: string
moduleId?: string,
propagation?: PropagationHint,
) {
if (typeof arg1 === 'function') {
return baseCreateComponent(arg1, moduleId);
return baseCreateComponent(arg1, moduleId, propagation);
} else {
return createComponentWithOptions(arg1);
}

View file

@ -33,6 +33,7 @@ export {
stringifyChunk,
voidElementNames,
} from './render/index.js';
export { renderTransition } from './transition.js';
export type {
AstroComponentFactory,
AstroComponentInstance,

View file

@ -0,0 +1,155 @@
import type {
SSRResult,
TransitionAnimation,
TransitionDirectionalAnimations,
TransitionAnimationValue,
} from '../../@types/astro';
import { markHTMLString } from './escape.js';
import { slide, fade } from '../../transitions/index.js';
const transitionNameMap = new WeakMap<SSRResult, number>();
function incrementTransitionNumber(result: SSRResult) {
let num = 1;
if(transitionNameMap.has(result)) {
num = transitionNameMap.get(result)! + 1;
}
transitionNameMap.set(result, num);
return num;
}
function createTransitionScope(result: SSRResult, hash: string) {
const num = incrementTransitionNumber(result);
return `astro-${hash}-${num}`;
}
export function renderTransition(result: SSRResult, hash: string, animationName: TransitionAnimationValue | undefined, transitionName: string) {
let animations: TransitionDirectionalAnimations | null = null;
switch(animationName) {
case 'fade': {
animations = fade();
break;
}
case 'slide': {
animations = slide();
break;
}
default: {
if(typeof animationName === 'object') {
animations = animationName;
}
}
}
const scope = createTransitionScope(result, hash);
// Default transition name is the scope of the element, ie HASH-1
if(!transitionName) {
transitionName = scope;
}
const styles = markHTMLString(`<style>[data-astro-transition-scope="${scope}"] {
view-transition-name: ${transitionName};
}
${!animations ? `` :
// Regular animations
`
::view-transition-old(${transitionName}) {
${stringifyAnimation(animations.forwards.old)}
}
[data-astro-transition-fallback=old] [data-astro-transition-scope="${scope}"] {
${stringifyAnimation(animations.forwards.old)}
}
::view-transition-new(${transitionName}) {
${stringifyAnimation(animations.forwards.new)}
}
[data-astro-transition-fallback=new] [data-astro-transition-scope="${scope}"] {
${stringifyAnimation(animations.forwards.new)}
}
[data-astro-transition=back]::view-transition-old(${transitionName}) {
${stringifyAnimation(animations.backwards.old)}
}
[data-astro-transition=back][data-astro-transition-fallback=old] [data-astro-transition-scope="${scope}"] {
${stringifyAnimation(animations.backwards.old)}
}
[data-astro-transition=back]::view-transition-new(${transitionName}) {
${stringifyAnimation(animations.backwards.new)}
}
[data-astro-transition=back][data-astro-transition-fallback=new] [data-astro-transition-scope="${scope}"] {
${stringifyAnimation(animations.backwards.new)}
}
`.trim()}
</style>`)
result._metadata.extraHead.push(styles);
return scope;
}
type AnimationBuilder = {
toString(): string;
[key: string]: string[] | ((k: string) => string);
}
function addAnimationProperty(builder: AnimationBuilder, prop: string, value: string | number) {
let arr = builder[prop];
if(Array.isArray(arr)) {
arr.push(value.toString());
} else {
builder[prop] = [value.toString()];
}
}
function animationBuilder(): AnimationBuilder {
return {
toString() {
let out = '';
for(let k in this) {
let value = this[k];
if(Array.isArray(value)) {
out += `\n\t${k}: ${value.join(', ')};`
}
}
return out;
}
};
}
function stringifyAnimation(anim: TransitionAnimation | TransitionAnimation[]): string {
if(Array.isArray(anim)) {
return stringifyAnimations(anim);
} else {
return stringifyAnimations([anim]);
}
}
function stringifyAnimations(anims: TransitionAnimation[]): string {
const builder = animationBuilder();
for(const anim of anims) {
/*300ms cubic-bezier(0.4, 0, 0.2, 1) both astroSlideFromRight;*/
if(anim.duration) {
addAnimationProperty(builder, 'animation-duration', toTimeValue(anim.duration));
}
if(anim.easing) {
addAnimationProperty(builder, 'animation-timing-function', anim.easing);
}
if(anim.direction) {
addAnimationProperty(builder, 'animation-direction', anim.direction);
}
if(anim.delay) {
addAnimationProperty(builder, 'animation-delay', anim.delay);
}
if(anim.fillMode) {
addAnimationProperty(builder, 'animation-fill-mode', anim.fillMode);
}
addAnimationProperty(builder, 'animation-name', anim.name);
}
return builder.toString();
}
function toTimeValue(num: number | string) {
return typeof num === 'number' ? num + 'ms' : num;
}

View file

@ -0,0 +1,65 @@
import type { TransitionDirectionalAnimations, TransitionAnimationPair } from '../@types/astro';
export function slide({
duration
}: {
duration?: string | number;
} = {}): TransitionDirectionalAnimations {
return {
forwards: {
old: [{
name: 'astroFadeOut',
duration: duration ?? '90ms',
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fillMode: 'both'
}, {
name: 'astroSlideToLeft',
duration: duration ?? '300ms',
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fillMode: 'both'
}],
new: [{
name: 'astroFadeIn',
duration: duration ?? '210ms',
easing: 'cubic-bezier(0, 0, 0.2, 1)',
delay: '90ms',
fillMode: 'both'
}, {
name: 'astroSlideFromRight',
duration: duration ?? '300ms',
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fillMode: 'both'
}]
},
backwards: {
old: [{ name: 'astroFadeOut' }, { name: 'astroSlideToRight' }],
new: [{ name: 'astroFadeIn' }, { name: 'astroSlideFromLeft' }]
}
};
}
export function fade({
duration
}: {
duration?: string | number;
} = {}): TransitionDirectionalAnimations {
const anim = {
old: {
name: 'astroFadeInOut',
duration: duration ?? '0.2s',
easing: 'linear',
fillMode: 'forwards',
},
new: {
name: 'astroFadeInOut',
duration: duration ?? '0.3s',
easing: 'linear',
fillMode: 'backwards',
}
} satisfies TransitionAnimationPair;
return {
forwards: anim,
backwards: anim,
};
}

View file

@ -0,0 +1,39 @@
import type { AstroConfig } from '../@types/astro';
import * as vite from 'vite';
import { AstroError } from '../core/errors/index.js';
const virtualModuleId = 'astro:transitions';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
// The virtual module for the astro:transitions namespace
export default function astroTransitions({ config }: { config: AstroConfig; }): vite.Plugin {
return {
name: 'astro:transitions',
async resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
if(!config.experimental.viewTransitions) {
throw new AstroError({
title: 'Experimental View Transitions not enabled',
message: `View Transitions support is experimental. To enable update your config to include:
export default defineConfig({
experimental: {
viewTransitions: true
}
})`
});
}
return `
export * from "astro/transitions";
export { default as ViewTransitions } from "astro/components/ViewTransitions.astro";
`;
}
},
};
}

View file

@ -12,6 +12,7 @@ describe('astro/src/core/compile', () => {
await cachedCompilation({
astroConfig: {
root: pathToFileURL('/'),
experimental: {}
},
viteConfig: await resolveConfig({ configFile: false }, 'serve'),
filename: '/src/pages/index.astro',

View file

@ -13,7 +13,7 @@ const viteConfig = await resolveConfig({ configFile: false }, 'serve');
async function compile(source, id) {
return await cachedFullCompilation({
compileProps: {
astroConfig: { root: pathToFileURL('/'), base: '/' },
astroConfig: { root: pathToFileURL('/'), base: '/', experimental: {} },
viteConfig,
filename: id,
source,

View file

@ -6,6 +6,8 @@
"declarationDir": "./dist",
"module": "ES2022",
"outDir": "./dist",
"target": "ES2021"
"target": "ES2021",
"jsx": "preserve",
"types": ["@types/dom-view-transitions", "network-information-types"]
}
}

View file

@ -7,7 +7,4 @@ export default defineConfig({
redirects: {
'/redirect': '/'
},
experimental: {
redirects: true
}
})

View file

@ -485,8 +485,8 @@ importers:
packages/astro:
dependencies:
'@astrojs/compiler':
specifier: ^1.5.3
version: 1.5.3
specifier: ^1.6.0
version: 1.6.0
'@astrojs/internal-helpers':
specifier: ^0.1.1
version: link:../internal-helpers
@ -523,6 +523,9 @@ importers:
'@types/babel__core':
specifier: ^7.20.1
version: 7.20.1
'@types/dom-view-transitions':
specifier: ^1.0.1
version: 1.0.1
'@types/yargs-parser':
specifier: ^21.0.0
version: 21.0.0
@ -592,6 +595,9 @@ importers:
mime:
specifier: ^3.0.0
version: 3.0.0
network-information-types:
specifier: ^0.1.1
version: 0.1.1(typescript@5.0.2)
ora:
specifier: ^6.3.1
version: 6.3.1
@ -1478,6 +1484,12 @@ importers:
specifier: ^18.1.0
version: 18.2.0(react@18.2.0)
packages/astro/e2e/fixtures/view-transitions:
dependencies:
astro:
specifier: workspace:*
version: link:../../..
packages/astro/e2e/fixtures/vue-component:
dependencies:
'@astrojs/mdx':
@ -5523,14 +5535,14 @@ packages:
sisteransi: 1.0.5
dev: false
/@astrojs/compiler@1.5.3:
resolution: {integrity: sha512-/HSFkJ+Yv+WUWSq0QVsIlhBKam5VUpGV+s8MvPguC/krHmw4Ww9TIgmfJSvV8/BN0sHJB7pCgf7yInae1zb+TQ==}
/@astrojs/compiler@1.6.0:
resolution: {integrity: sha512-vxuzp09jAW/ZQ8C4Itf6/OsF76TNjBQC06FNpcayKOzxYkCGHTLh7+0lF4ywmG/fDgSc+f1x7kKxxEKl4nqXvQ==}
/@astrojs/language-server@1.0.0:
resolution: {integrity: sha512-oEw7AwJmzjgy6HC9f5IdrphZ1GVgfV/+7xQuyf52cpTiRWd/tJISK3MsKP0cDkVlfodmNABNFnAaAWuLZEiiiA==}
hasBin: true
dependencies:
'@astrojs/compiler': 1.5.3
'@astrojs/compiler': 1.6.0
'@jridgewell/trace-mapping': 0.3.18
'@vscode/emmet-helper': 2.8.8
events: 3.3.0
@ -8580,6 +8592,10 @@ packages:
resolution: {integrity: sha512-OyiZ3jEKu7RtGO1yp9oOdK0cTwZ/10oE9PDJ6fyN3r9T5wkyOcvr6awdugjYdqF6KVO5eUvt7jx7rk2Eylufow==}
dev: true
/@types/dom-view-transitions@1.0.1:
resolution: {integrity: sha512-A9S1ijj/4MX06I1W/6on8lhaYyq1Ir7gaOvfllW1o4RzVWW88HAeqX0pUx9VgOLnNpdiGeUW2CTkg18p5LWIrA==}
dev: false
/@types/estree-jsx@1.0.0:
resolution: {integrity: sha512-3qvGd0z8F2ENTGr/GG1yViqfiKmRfrXVx5sJyHGFu3z7m5g5utCQtGp/g29JnjflhtQJBv1WDQukHiT58xPcYQ==}
dependencies:
@ -14232,6 +14248,14 @@ packages:
engines: {node: '>= 0.4.0'}
dev: true
/network-information-types@0.1.1(typescript@5.0.2):
resolution: {integrity: sha512-mLXNafJYOkiJB6IlF727YWssTRpXitR+tKSLyA5VAdBi3SOvLf5gtizHgxf241YHPWocnAO/fAhVrB/68tPHDw==}
peerDependencies:
typescript: '>= 3.0.0'
dependencies:
typescript: 5.0.2
dev: false
/nice-try@1.0.5:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
dev: true
@ -15298,7 +15322,7 @@ packages:
resolution: {integrity: sha512-dPzop0gKZyVGpTDQmfy+e7FKXC9JT3mlpfYA2diOVz+Ui+QR1U4G/s+OesKl2Hib2JJOtAYJs/l+ovgT0ljlFA==}
engines: {node: ^14.15.0 || >=16.0.0, pnpm: '>=7.14.0'}
dependencies:
'@astrojs/compiler': 1.5.3
'@astrojs/compiler': 1.6.0
prettier: 2.8.8
sass-formatter: 0.7.6
dev: true
@ -15307,7 +15331,7 @@ packages:
resolution: {integrity: sha512-lJ/mG/Lz/ccSwNtwqpFS126mtMVzFVyYv0ddTF9wqwrEG4seECjKDAyw/oGv915rAcJi8jr89990nqfpmG+qdg==}
engines: {node: ^14.15.0 || >=16.0.0, pnpm: '>=7.14.0'}
dependencies:
'@astrojs/compiler': 1.5.3
'@astrojs/compiler': 1.6.0
prettier: 2.8.8
sass-formatter: 0.7.6
synckit: 0.8.5