Changed the behavior of the 'more' bar into something less annoying, and re-did the entire project grid component chain.
This commit is contained in:
@@ -1,127 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
||||
import { MoreSection, PROFILE } from "@/data/content";
|
||||
import { useStaggerReveal, useWiggle } from "@/hooks/useAnimations";
|
||||
import "@/styles/more-section.css"
|
||||
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 [delayTimer, setDelayTimer] = useState<number | null>(null);
|
||||
const [hovered, setHovered] = useState<boolean | null>(null);
|
||||
const [morePosition, setMorePosition] = useState<number>(PROFILE.moreSectionsStart ?? 0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [armed, setArmed] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
|
||||
|
||||
const { wiggling, progress, handlers } = useWiggle(500, () => {});
|
||||
|
||||
function handleSectionExpand(){
|
||||
setMorePosition(morePosition + 1);
|
||||
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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function handleEnter(){
|
||||
if (delayTimer) return;
|
||||
setHovered(true);
|
||||
let timer = window.setTimeout(() => {
|
||||
handleSectionExpand();
|
||||
}, 500);
|
||||
setDelayTimer(timer);
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
if (delayTimer){
|
||||
clearTimeout(delayTimer);
|
||||
setDelayTimer(null);
|
||||
}
|
||||
setHovered(false);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="more-section-container"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
width: "100%",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
{PROFILE.moreSections.map((item: MoreSection, i)=>{
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
// display: i < morePosition ? "block" : "none",
|
||||
opacity: i < morePosition ? 1.0 : 0.0,
|
||||
transition: "all 0.3s ease",
|
||||
overflow: "hidden",
|
||||
minHeight: i < morePosition ? "5em" : "0em",
|
||||
height: i < morePosition ? "auto" : "0",
|
||||
}}>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
<div {...handlers} className={`drop-handle ${wiggling ? "wiggle" : ""}`}
|
||||
onMouseEnter={
|
||||
() => handleEnter()
|
||||
}
|
||||
onMouseLeave={() => handleLeave()}
|
||||
onTouchStart={
|
||||
() => handleEnter()
|
||||
}
|
||||
onTouchEnd={()=>handleLeave()}
|
||||
onClick={() => {
|
||||
if (delayTimer){
|
||||
clearTimeout(delayTimer);
|
||||
}
|
||||
handleSectionExpand();
|
||||
}}
|
||||
style={{
|
||||
opacity: morePosition < PROFILE.moreSections.length ? 1.0 : 0.0,
|
||||
backgroundColor: hovered ? "var(--surface)" : "var(--bg-raised",
|
||||
width: "100%",
|
||||
height: "2em",
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
margin: "0 0 40px 0",
|
||||
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
|
||||
bottom: hovered ? "-10px" : "0px",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div style={{ width: "100%" }}>
|
||||
{above.map((item, i) => (
|
||||
<Section key={i} item={item} />
|
||||
))}
|
||||
|
||||
<svg height={"30px"} width={"30px"} fill="var(--fg-secondary)" version="1.1" id="Capa_1" style={{scale:"0.5"}} >
|
||||
<g>
|
||||
<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,0
|
||||
l11.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"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span style={{
|
||||
fontFamily: "var(--sans)",
|
||||
fontSize: 12,
|
||||
lineHeight: 0.5,
|
||||
color: "var(--fg-secondary)",
|
||||
alignSelf: "center",
|
||||
textTransform: "uppercase"
|
||||
}}>more?</span>
|
||||
{/* 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user