173 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|