feat: show contextual navigation loading
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { PortfolioLink as Link } from './PortfolioNavigation';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import type { PublicPortfolio } from '@/server/content';
|
||||
import type { Project } from '@/types/projects';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } fro
|
||||
import { X } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { PublicPortfolio } from '@/server/content';
|
||||
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
||||
import { useDismissCaseStudy } from './PortfolioNavigation';
|
||||
import { CaseStudyShare } from './CaseStudyShare';
|
||||
import { useDrawerDrag } from './useDrawerDrag';
|
||||
import styles from './CaseStudyDrawer.module.css';
|
||||
|
||||
13
src/components/CaseStudyLoading.tsx
Normal file
13
src/components/CaseStudyLoading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
import { usePortfolioNavigation } from './PortfolioNavigation';
|
||||
import styles from './LoadingIndicator.module.css';
|
||||
|
||||
export function CaseStudyLoading() {
|
||||
const { loadingLabel } = usePortfolioNavigation();
|
||||
return <>
|
||||
<h1 id="case-study-title" className={styles.hidden}>{loadingLabel}</h1>
|
||||
<div id="case-study-introduction"><LoadingIndicator label={loadingLabel} /></div>
|
||||
</>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { portfolioPath } from '@/content/contracts';
|
||||
import Link from 'next/link';
|
||||
import { PortfolioLink as Link } from './PortfolioNavigation';
|
||||
import Image from 'next/image';
|
||||
import { languageFlag } from '@/content/language-flags';
|
||||
import { Check, Globe, Languages, Moon, Sun } from 'lucide-react';
|
||||
@@ -50,7 +50,7 @@ export function HeroControls({ portfolio }: PortfolioProps) {
|
||||
{portfolio.languages.map(language => {
|
||||
const flag = languageFlag(language.code, language.flagCountry);
|
||||
return <Link key={language.code}
|
||||
href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
|
||||
kind="language" href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
|
||||
aria-current={locale === language.code ? 'page' : undefined} className={styles.languageOption}>
|
||||
<span className={styles.languageOptionLabel}>
|
||||
{flag ? <Image src={flag} alt="" aria-hidden="true" width={20} height={20} unoptimized className={styles.languageFlag} /> : <Globe size={20} aria-hidden="true" className={styles.languageFlag} />}
|
||||
|
||||
17
src/components/InitialLoading.tsx
Normal file
17
src/components/InitialLoading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
|
||||
const subscribe = () => () => {};
|
||||
const ready = () => true;
|
||||
const waiting = () => false;
|
||||
|
||||
export function InitialLoading({ label }: { label: string }) {
|
||||
const hydrated = useSyncExternalStore(subscribe, ready, waiting);
|
||||
if (hydrated) return null;
|
||||
return <div data-initial-loading>
|
||||
<LoadingIndicator label={label} fullPage />
|
||||
<noscript><style>{'[data-initial-loading] { display: none; }'}</style></noscript>
|
||||
</div>;
|
||||
}
|
||||
38
src/components/LoadingIndicator.module.css
Normal file
38
src/components/LoadingIndicator.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.indicator {
|
||||
display: flex;
|
||||
min-height: 15rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.fullPage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--action-link);
|
||||
animation: turn 900ms linear infinite;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes turn { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner { animation: none; }
|
||||
}
|
||||
9
src/components/LoadingIndicator.tsx
Normal file
9
src/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import styles from './LoadingIndicator.module.css';
|
||||
|
||||
export function LoadingIndicator({ label, fullPage = false }: { label: string; fullPage?: boolean }) {
|
||||
return <div className={`${styles.indicator} ${fullPage ? styles.fullPage : ''}`} role="status" aria-live="polite" aria-busy="true">
|
||||
<LoaderCircle className={styles.spinner} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>;
|
||||
}
|
||||
14
src/components/PortfolioError.tsx
Normal file
14
src/components/PortfolioError.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { dictionaries } from '@/content/messages';
|
||||
import styles from './LoadingIndicator.module.css';
|
||||
|
||||
export function PortfolioError({ retry }: { retry: () => void }) {
|
||||
const { locale } = useParams<{ locale?: string }>();
|
||||
const copy = (dictionaries[locale ?? 'en'] ?? dictionaries.en).loading;
|
||||
return <div className={styles.indicator} role="alert">
|
||||
<h1 id="case-study-title">{copy.error}</h1>
|
||||
<button type="button" onClick={retry}>{copy.retry}</button>
|
||||
</div>;
|
||||
}
|
||||
27
src/components/PortfolioNavigation.tsx
Normal file
27
src/components/PortfolioNavigation.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ComponentProps } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type NavigationOptions = { replace?: boolean; scroll?: boolean; kind?: 'project' | 'language' };
|
||||
export const PortfolioNavigationContext = createContext<{
|
||||
navigate: (href: string, options: NavigationOptions) => void;
|
||||
dismiss: () => void;
|
||||
loadingLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
export function usePortfolioNavigation() {
|
||||
const navigation = useContext(PortfolioNavigationContext);
|
||||
if (!navigation) throw new Error('Portfolio navigation requires PortfolioRouteShell');
|
||||
return navigation;
|
||||
}
|
||||
|
||||
export function useDismissCaseStudy() { return usePortfolioNavigation().dismiss; }
|
||||
|
||||
export function PortfolioLink({ href, kind = 'project', replace, scroll, ...props }: Omit<ComponentProps<typeof Link>, 'href' | 'onNavigate'> & { href: string; kind?: 'project' | 'language' }) {
|
||||
const { navigate } = usePortfolioNavigation();
|
||||
return <Link {...props} href={href} replace={replace} scroll={scroll} onNavigate={event => {
|
||||
event.preventDefault();
|
||||
navigate(href, { kind, replace, scroll });
|
||||
}} />;
|
||||
}
|
||||
@@ -1,31 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
const PortfolioRouteContext = createContext<(() => void) | null>(null);
|
||||
import type { PublicPortfolio } from '@/server/content';
|
||||
import { PortfolioNavigationContext } from './PortfolioNavigation';
|
||||
import { CaseStudyDrawer } from './CaseStudyDrawer';
|
||||
import { CaseStudyLoading } from './CaseStudyLoading';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
|
||||
export function PortfolioRouteShell({
|
||||
children,
|
||||
homePath,
|
||||
background,
|
||||
portfolio,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
homePath: string;
|
||||
background: ReactNode;
|
||||
portfolio: PublicPortfolio;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<'project' | 'language' | 'dismiss'>('project');
|
||||
const pending = isPending && target !== null;
|
||||
const languagePending = pending && kind === 'language';
|
||||
const showDrawer = pathname !== homePath || (pending && kind === 'project');
|
||||
const previousPath = useRef(pathname);
|
||||
const canReturnWithBack = useRef(false);
|
||||
const returnScrollY = useRef<number | null>(null);
|
||||
const previousScrollRestoration = useRef<ScrollRestoration>('auto');
|
||||
|
||||
useEffect(() => {
|
||||
const onPageShow = (event: PageTransitionEvent) => { if (event.persisted) setTarget(null); };
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
return () => window.removeEventListener('pageshow', onPageShow);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === homePath) {
|
||||
canReturnWithBack.current = false;
|
||||
@@ -51,22 +70,35 @@ export function PortfolioRouteShell({
|
||||
}, [homePath, pathname]);
|
||||
|
||||
const dismissCaseStudy = useCallback(() => {
|
||||
if (canReturnWithBack.current) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace(homePath, { scroll: false });
|
||||
setKind('dismiss');
|
||||
setTarget(homePath);
|
||||
startTransition(() => {
|
||||
if (canReturnWithBack.current) router.back();
|
||||
else router.replace(homePath, { scroll: false });
|
||||
});
|
||||
}, [homePath, router]);
|
||||
|
||||
return (
|
||||
<PortfolioRouteContext.Provider value={dismissCaseStudy}>
|
||||
{children}
|
||||
</PortfolioRouteContext.Provider>
|
||||
<PortfolioNavigationContext.Provider value={{
|
||||
dismiss: dismissCaseStudy,
|
||||
loadingLabel: portfolio.messages.loading.project,
|
||||
navigate: (href, options) => {
|
||||
if (href === pathname && !pending) return;
|
||||
setTarget(href);
|
||||
setKind(options.kind ?? 'project');
|
||||
startTransition(() => {
|
||||
if (options.replace) router.replace(href, { scroll: options.scroll });
|
||||
else router.push(href, { scroll: options.scroll });
|
||||
});
|
||||
},
|
||||
}}>
|
||||
<div inert={languagePending} aria-busy={languagePending || undefined}>
|
||||
{background}
|
||||
{showDrawer ? <CaseStudyDrawer portfolio={portfolio}>
|
||||
{pending && kind === 'project' ? <CaseStudyLoading /> : children}
|
||||
</CaseStudyDrawer> : children}
|
||||
</div>
|
||||
{languagePending && <LoadingIndicator label={portfolio.messages.loading.language} fullPage />}
|
||||
</PortfolioNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDismissCaseStudy() {
|
||||
const dismiss = useContext(PortfolioRouteContext);
|
||||
if (!dismiss) throw new Error('useDismissCaseStudy must be used within PortfolioRouteShell');
|
||||
return dismiss;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { formatMessage } from '@/content/contracts';
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PortfolioLink as Link } from './PortfolioNavigation';
|
||||
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||
import { projectPath } from '@/content/contracts';
|
||||
import type { PortfolioProps } from '@/server/content';
|
||||
|
||||
Reference in New Issue
Block a user