Files
my-protfolio/src/components/CaseStudyDrawer.tsx

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>
);
}