Compare commits
6 Commits
0090718869
...
92e4a01641
| Author | SHA1 | Date | |
|---|---|---|---|
| 92e4a01641 | |||
| 9cf8caaead | |||
| e518fc46a9 | |||
| 481dda3c95 | |||
| d28343e22c | |||
| 7cba3053f4 |
@@ -8,6 +8,9 @@ const isExport = process.env.STATIC_EXPORT === 'true';
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
...(isExport ? { output: 'export' } : {}),
|
...(isExport ? { output: 'export' } : {}),
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
|
experimental: {
|
||||||
|
viewTransition: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ describe('SectionAccordion', () => {
|
|||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content wrapper has animate-fadeIn class', () => {
|
it('content wrapper has section-content class', () => {
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />);
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
||||||
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
expect(container.querySelector('.section-content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { ViewTransitionWrapper } from '$shared/ui';
|
||||||
|
|
||||||
interface SectionAccordionProps {
|
interface SectionAccordionProps {
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,7 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
|
<ViewTransitionWrapper name="section-content">
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<h1
|
<h1
|
||||||
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
||||||
@@ -44,15 +46,15 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
{number}. {title}
|
{number}. {title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fadeIn">{children}</div>
|
</ViewTransitionWrapper>
|
||||||
|
<ViewTransitionWrapper name="section-body">
|
||||||
|
<div className="section-content">{children}</div>
|
||||||
|
</ViewTransitionWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link href={href} className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0">
|
||||||
href={href}
|
|
||||||
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group border-b-0 hover:border-b-0"
|
|
||||||
>
|
|
||||||
<h2
|
<h2
|
||||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200"
|
||||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
||||||
>
|
>
|
||||||
{number}. {title}
|
{number}. {title}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { ExperienceCard } from './ui/ExperienceCard';
|
export { ExperienceCard } from './ui';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ExperienceCard } from './ExperienceCard/ExperienceCard';
|
||||||
@@ -1,3 +1 @@
|
|||||||
export { DetailedProjectCard } from './ui/DetailedProjectCard';
|
export { DetailedProjectCard, ProjectCard, ProjectMetadata } from './ui';
|
||||||
export { ProjectCard } from './ui/ProjectCard';
|
|
||||||
export { ProjectMetadata } from './ui/ProjectMetadata';
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Card } from '$shared/ui';
|
import { Card } from '$shared/ui';
|
||||||
import { ProjectMetadata } from './ProjectMetadata';
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
|
||||||
|
export { ProjectCard } from './ProjectCard/ProjectCard';
|
||||||
|
export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
|
||||||
@@ -75,6 +75,15 @@
|
|||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
|
|
||||||
|
/* === ANIMATION === */
|
||||||
|
--ease-default: ease;
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
||||||
|
--duration-fast: 100ms;
|
||||||
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 350ms;
|
||||||
|
--duration-spring: 380ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -230,17 +239,93 @@
|
|||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Section content enter animation (initial render, no navigation) */
|
||||||
@keyframes fadeIn {
|
.section-content {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-slow) var(--ease-default),
|
||||||
|
transform var(--duration-slow) var(--ease-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.section-content {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cross-section view transition (navigation between sections) */
|
||||||
|
::view-transition-old(section-content) {
|
||||||
|
animation-name: section-fade-out;
|
||||||
|
animation-duration: var(--duration-normal);
|
||||||
|
animation-timing-function: var(--ease-default);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(section-content) {
|
||||||
|
animation-name: section-fade-in;
|
||||||
|
animation-duration: var(--duration-spring);
|
||||||
|
animation-timing-function: var(--ease-spring);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(12px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animate-fadeIn {
|
|
||||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
/* Section body: instant blur-out, clean slide-in */
|
||||||
|
::view-transition-old(section-body) {
|
||||||
|
animation-name: section-body-out;
|
||||||
|
animation-duration: var(--duration-fast);
|
||||||
|
animation-timing-function: var(--ease-default);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(section-body) {
|
||||||
|
animation-name: section-body-in;
|
||||||
|
animation-duration: var(--duration-slow);
|
||||||
|
animation-delay: var(--duration-normal);
|
||||||
|
animation-timing-function: var(--ease-decelerate);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-body-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-body-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ViewTransitionWrapper } from './ui/ViewTransitionWrapper';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||||
|
|
||||||
|
describe('ViewTransitionWrapper', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>Hello</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple children', () => {
|
||||||
|
render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>First</p>
|
||||||
|
<p>Second</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('First')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Second')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add an extra wrapper DOM element', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>Content</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild?.nodeName).toBe('P');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VT is undefined in stable react-dom (test env / non-experimental builds).
|
||||||
|
* Fall back to Fragment so children render and the name prop is silently ignored.
|
||||||
|
*/
|
||||||
|
const Transition = (VT ?? Fragment) as typeof VT;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Maps to the view-transition-name CSS property
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Content to animate
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps children in React's ViewTransition when available,
|
||||||
|
* falling back to a Fragment in environments where ViewTransition is undefined.
|
||||||
|
*/
|
||||||
|
export function ViewTransitionWrapper({ name, children }: Props) {
|
||||||
|
return <Transition name={name}>{children}</Transition>;
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export { RichText } from './RichText';
|
|||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
|
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"types": ["vitest/globals"],
|
"types": ["vitest/globals", "react/canary"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": ["./*"],
|
||||||
"$shared/*": ["./src/shared/*"],
|
"$shared/*": ["./src/shared/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user