148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { usePathname } from 'next/navigation';
|
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
|
import { CaseStudyShare } from './CaseStudyShare';
|
|
import { useDrawerDrag } from './useDrawerDrag';
|
|
import styles from './CaseStudyDrawer.module.css';
|
|
|
|
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 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-initial-focus]')?.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(() => {
|
|
dialogRef.current?.querySelector<HTMLElement>('[data-case-study-initial-focus]')?.focus();
|
|
});
|
|
}, [pathname]);
|
|
|
|
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;
|
|
};
|
|
|
|
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}>
|
|
<CaseStudyShare locale={locale} />
|
|
<button
|
|
type="button"
|
|
className={styles.iconButton}
|
|
onClick={(event) => requestDismiss(event.detail !== 0)}
|
|
aria-label={copy.closeCaseStudy}
|
|
data-case-study-initial-focus
|
|
autoFocus
|
|
>
|
|
<X size={21} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div ref={scrollRef} className={styles.scrollArea}>{children}</div>
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|