Files
hwilliams-dev/components/ProfileMore.tsx
2026-08-14 20:06:50 -04:00

173 lines
4.7 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { MoreSection, PROFILE } from "@/data/content";
import { useFullBleed, usePrefersReducedMotion } from "@/hooks/useAnimations";
import "@/styles/more-section.css";
function useThresholdTint(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const root = document.documentElement;
const publish = () => {
const rect = el.getBoundingClientRect();
// The rule sits at the element's vertical centre.
const y = rect.top + window.scrollY + rect.height / 2;
root.style.setProperty("--threshold-y", `${Math.round(y)}px`);
};
publish();
const observer = new ResizeObserver(publish);
observer.observe(root);
observer.observe(el);
return () => {
observer.disconnect();
root.style.removeProperty("--threshold-y");
};
}, [ref]);
}
/** Per-section stagger once the threshold opens. */
const STAGGER_MS = 90;
export default function ProfileMore() {
const [open, setOpen] = useState(false);
const [armed, setArmed] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const { ref: bleedRef, style: bleedStyle } = useFullBleed<HTMLDivElement>();
useThresholdTint(bleedRef);
const start = PROFILE.moreSectionsStart ?? 0;
const above = PROFILE.moreSections.slice(0, start);
const below = PROFILE.moreSections.slice(start);
const cross = useCallback((e: any) => {
if(e.pointerType=='touch'){
return;
}
setArmed(true);
setOpen(true);
}, []);
const toggle = useCallback(() => {
setArmed(true);
setOpen((prev) => !prev);
}, []);
if (below.length === 0) {
return (
<>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
</>
);
}
return (
<div style={{ width: "100%" }}>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
{/* The threshold. The rule sits at this box's vertical centre, and the
rule and glow bleed to the viewport edges via the measured offset.
Entering the band at all is what counts as crossing it. */}
<div ref={bleedRef} style={{ ...bleedStyle, position: "relative" }} onPointerEnter={cross}>
<button
type="button"
className="threshold"
data-armed={armed || open}
data-open={open}
aria-expanded={open}
aria-controls="profile-more-reveal"
onFocus={() => setArmed(true)}
onBlur={() => setArmed(open)}
onClick={() => toggle()}
>
<span className="threshold-glow" aria-hidden="true" />
<span className="threshold-rule" aria-hidden="true" />
<span className="threshold-label">
<svg
className="threshold-chevron"
viewBox="0 0 31 31"
fill="currentColor"
aria-hidden="true"
>
<path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z" />
</svg>
{open ? "less" : "more?"}
</span>
</button>
</div>
{/* Everything below the line, revealed in one motion. */}
<div id="profile-more-reveal" className="more-reveal" data-open={open}>
<div className="more-reveal-inner">
<div style={{ paddingTop: 32 }}>
{below.map((item, i) => (
<Section
key={i}
item={item}
revealed
style={{
transitionDelay: reducedMotion ? "0ms" : `${120 + i * STAGGER_MS}ms`,
}}
/>
))}
</div>
</div>
</div>
</div>
);
}
function Section({
item,
revealed = false,
style,
}: {
item: MoreSection;
revealed?: boolean;
style?: CSSProperties;
}) {
return (
<div className={revealed ? "more-section" : undefined} style={style}>
{item.title && (
<h3
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{item.title}
</h3>
)}
<p
style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}
>
{item.text}
</p>
</div>
);
}