feat: add route-backed project case studies
This commit is contained in:
158
src/components/CaseStudyDrawer.tsx
Normal file
158
src/components/CaseStudyDrawer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
|
||||
import { Check, Copy, X } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
||||
import { useDrawerDrag } from './useDrawerDrag';
|
||||
import styles from './CaseStudyDrawer.module.css';
|
||||
|
||||
type CopyState = 'idle' | 'copied' | 'error';
|
||||
|
||||
export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) {
|
||||
const copy = messagesByLocale[locale].projects;
|
||||
const pathname = usePathname();
|
||||
const dismissRoute = useDismissCaseStudy();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const backdropPressed = useRef(false);
|
||||
const closing = useRef(false);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const backgroundScrollY = useRef(0);
|
||||
const [copyState, setCopyState] = useState<CopyState>('idle');
|
||||
|
||||
const finishDismiss = useCallback(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog?.open) dialog.close();
|
||||
returnFocusRef.current?.focus({ preventScroll: true });
|
||||
dismissRoute();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: backgroundScrollY.current, behavior: 'instant' });
|
||||
returnFocusRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
}, [dismissRoute]);
|
||||
|
||||
const requestDismiss = useCallback((animate = true) => {
|
||||
if (closing.current) return;
|
||||
closing.current = true;
|
||||
const dialog = dialogRef.current;
|
||||
const sheet = sheetRef.current;
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (!animate || reduceMotion || !dialog || !sheet) {
|
||||
finishDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.dataset.state = 'closing';
|
||||
sheet.style.transition = 'transform 180ms var(--motion-ease-out)';
|
||||
sheet.style.transform = 'translateY(100%)';
|
||||
window.setTimeout(finishDismiss, 180);
|
||||
}, [finishDismiss]);
|
||||
|
||||
const dragHandlers = useDrawerDrag({ sheetRef, onDismiss: requestDismiss });
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const root = document.documentElement;
|
||||
const previousOverflow = root.style.overflow;
|
||||
const skipMovement = root.dataset.caseStudyInput === 'keyboard';
|
||||
const recordedScrollY = Number(root.dataset.caseStudyScrollY);
|
||||
const activeElement = document.activeElement;
|
||||
returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body
|
||||
? activeElement
|
||||
: null;
|
||||
backgroundScrollY.current = Number.isFinite(recordedScrollY) ? recordedScrollY : window.scrollY;
|
||||
delete root.dataset.caseStudyInput;
|
||||
dialog.dataset.motion = skipMovement ? 'none' : 'standard';
|
||||
dialog.showModal();
|
||||
root.style.overflow = 'hidden';
|
||||
requestAnimationFrame(() => {
|
||||
dialog.dataset.state = 'open';
|
||||
dialog.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
root.style.overflow = previousOverflow;
|
||||
if (dialog.open) dialog.close();
|
||||
if (returnFocusRef.current) {
|
||||
const restoreScroll = () => window.scrollTo({
|
||||
top: backgroundScrollY.current,
|
||||
behavior: 'instant',
|
||||
});
|
||||
restoreScroll();
|
||||
window.setTimeout(restoreScroll, 120);
|
||||
window.setTimeout(restoreScroll, 600);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
|
||||
requestAnimationFrame(() => {
|
||||
setCopyState('idle');
|
||||
dialogRef.current?.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
const copyCurrentLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setCopyState('copied');
|
||||
} catch {
|
||||
setCopyState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropDown = (event: PointerEvent<HTMLDialogElement>) => {
|
||||
backdropPressed.current = event.target === event.currentTarget;
|
||||
};
|
||||
|
||||
const handleBackdropUp = (event: PointerEvent<HTMLDialogElement>) => {
|
||||
if (backdropPressed.current && event.target === event.currentTarget) requestDismiss();
|
||||
backdropPressed.current = false;
|
||||
};
|
||||
|
||||
const feedback = copyState === 'copied' ? copy.linkCopied : copyState === 'error' ? copy.copyFailed : '';
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={styles.dialog}
|
||||
aria-labelledby="case-study-title"
|
||||
aria-describedby="case-study-introduction"
|
||||
data-state="opening"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
requestDismiss(false);
|
||||
}}
|
||||
onPointerDown={handleBackdropDown}
|
||||
onPointerUp={handleBackdropUp}
|
||||
>
|
||||
<div ref={sheetRef} className={styles.sheet}>
|
||||
<div className={styles.utilityHeader}>
|
||||
<div className={styles.dragTarget} aria-hidden="true" {...dragHandlers}>
|
||||
<span className={styles.dragHandle} />
|
||||
</div>
|
||||
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.actionButton} onClick={copyCurrentLink} aria-label={copy.copyLink}>
|
||||
{copyState === 'copied' ? <Check size={19} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
<span className={styles.actionText}>{copyState === 'copied' ? copy.linkCopied : copy.copyLink}</span>
|
||||
</button>
|
||||
<button type="button" className={styles.iconButton} onClick={(event) => requestDismiss(event.detail !== 0)} aria-label={copy.closeCaseStudy}>
|
||||
<X size={21} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.liveRegion} aria-live="polite">{feedback}</span>
|
||||
</div>
|
||||
<div ref={scrollRef} className={styles.scrollArea}>{children}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user