Files
hwilliams-dev/components/ProfileMore.tsx

184 lines
5.3 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";
/**
* Publishes the threshold rule's distance from the top of the document as
* `--threshold-y`, which the root background gradient reads to tint everything
* below it. Keeping this on the background instead of in an element means it
* covers the rest of the page without adding to the scrollable area.
*/
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);
// Sections before `moreSectionsStart` are always visible; the rest are the
// payload that lives below the threshold.
const start = PROFILE.moreSectionsStart ?? 0;
const above = PROFILE.moreSections.slice(0, start);
const below = PROFILE.moreSections.slice(start);
// Crossing the line opens it. The reveal then latches -- crossing back up
// leaves it open, so a stray hover never yanks content out from under someone
// mid-read. Collapsing is deliberate only: a click on the label.
const cross = useCallback(() => {
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" }}
onMouseEnter={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>
);
}