Glow-Überschriften: Fix A aus der Hover-Analyse
Der Glow der Überschriften (Leistungen, Ablauf) wacht jetzt etwa 180 px vor der SVG-Box auf (Zeiger-Tracking am window, nur feine Zeiger), bildet den Mauszeiger über getScreenCTM korrekt auf die viewBox ab, blendet weich per Opacity ein und aus und klebt auf Touch-Geräten nicht mehr. Screenreader hören das Wort einmal (role=img mit aria-label, Textknoten aria-hidden). Der Diff aus /root/reports/webklar-relaunch-2026-10-01/ hover/fix/text-hover-effect.fixA.diff ließ sich unverändert anwenden. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d12bbc06a
commit
194301c6af
2 files changed
+179
-25
No files matched your search
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useId } from "react";
|
||||
import { useRef, useState, useEffect, useId } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
// Wie weit (in CSS-px) um die SVG-Box herum der Mauszeiger den Glow schon weckt.
|
||||
// Der Glow-Radius ist 80 viewBox-Einheiten (~300 px), dahinter ist er ohnehin schwarz.
|
||||
const WAKE_DISTANCE_PX = 260;
|
||||
|
||||
export const TextHoverEffect = ({
|
||||
text,
|
||||
}: {
|
||||
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
|
||||
duration?: number;
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [active, setActive] = useState(false);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
|
||||
const id = useId();
|
||||
const gradientId = `textGradient-${id}`;
|
||||
const revealMaskId = `revealMask-${id}`;
|
||||
const textMaskId = `textMask-${id}`;
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
// Map screen coords to viewBox coords (0 0 300 60)
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 300;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 60;
|
||||
setCursorPos({ x, y });
|
||||
},
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
// Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
|
||||
if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
||||
|
||||
let last: { x: number; y: number } | null = null;
|
||||
let raf = 0;
|
||||
let isActive = false;
|
||||
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
if (!last) return;
|
||||
const r = svg.getBoundingClientRect();
|
||||
const dx = Math.max(r.left - last.x, 0, last.x - r.right);
|
||||
const dy = Math.max(r.top - last.y, 0, last.y - r.bottom);
|
||||
const near = Math.hypot(dx, dy) <= WAKE_DISTANCE_PX;
|
||||
if (near !== isActive) {
|
||||
isActive = near;
|
||||
setActive(near);
|
||||
}
|
||||
const m = svg.getScreenCTM();
|
||||
if (near && m) {
|
||||
// echte Bildschirm -> viewBox-Abbildung (beruecksichtigt "meet"-Letterbox)
|
||||
const p = new DOMPoint(last.x, last.y).matrixTransform(m.inverse());
|
||||
setCursorPos({ x: p.x, y: p.y });
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!raf) raf = requestAnimationFrame(update);
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
last = { x: e.clientX, y: e.clientY };
|
||||
schedule();
|
||||
};
|
||||
const onLeave = () => {
|
||||
last = null;
|
||||
isActive = false;
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove, { passive: true });
|
||||
window.addEventListener("scroll", schedule, { passive: true });
|
||||
document.documentElement.addEventListener("mouseleave", onLeave);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("scroll", schedule);
|
||||
document.documentElement.removeEventListener("mouseleave", onLeave);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
|
||||
height="100%"
|
||||
viewBox="0 0 300 60"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
role="img"
|
||||
aria-label={text}
|
||||
className="select-none"
|
||||
>
|
||||
<defs>
|
||||
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
|
||||
</radialGradient>
|
||||
|
||||
<mask id={textMaskId}>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="300"
|
||||
height="60"
|
||||
fill={hovered ? `url(#${revealMaskId})` : "black"}
|
||||
/>
|
||||
<rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
strokeWidth="0.3"
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||
style={{ opacity: 0.15, fontSize: "2rem" }}
|
||||
>
|
||||
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
strokeWidth="0.3"
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||
style={{ fontSize: "2rem" }}
|
||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
|
||||
{text}
|
||||
</motion.text>
|
||||
|
||||
{/* Colored gradient revealed on hover */}
|
||||
{/* Colored gradient revealed near the pointer */}
|
||||
<text
|
||||
x="150"
|
||||
y="30"
|
||||
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="0.3"
|
||||
mask={`url(#${textMaskId})`}
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent"
|
||||
style={{ fontSize: "2rem" }}
|
||||
style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
|
||||
@@ -486,3 +486,117 @@ describe("A3 Navigation: Theme-Schalter, Kontakt und Logo bekommen echte Mauskli
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("A4 Glow-Überschrift (Fix A)", () => {
|
||||
const BOX = { left: 100, top: 400, right: 1300, bottom: 624, width: 1200, height: 224, x: 100, y: 400 };
|
||||
|
||||
/** Browser-Umgebung für die Überschrift: Maus-Gerät, feste Box, manuell abgespielte Animation-Frames. */
|
||||
const umgebung = (feinerZeiger = true) => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(query) =>
|
||||
({
|
||||
matches: feinerZeiger,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as MediaQueryList,
|
||||
);
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ ...BOX, toJSON: () => ({}) });
|
||||
// jsdom hat weder getScreenCTM noch DOMPoint: Abbildung Bildschirm -> viewBox (300 x 60) mit Faktor 4
|
||||
Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", {
|
||||
configurable: true,
|
||||
value: () => ({ inverse: () => ({}) }),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"DOMPoint",
|
||||
class {
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number,
|
||||
) {}
|
||||
matrixTransform() {
|
||||
return { x: (this.x - BOX.left) / 4, y: (this.y - BOX.top) / 4 };
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => act(() => frames.splice(0).forEach((cb) => cb(0)));
|
||||
};
|
||||
afterEach(() => {
|
||||
delete (SVGSVGElement.prototype as { getScreenCTM?: unknown }).getScreenCTM;
|
||||
});
|
||||
|
||||
/** jsdom kennt keine PointerEvents: MouseEvent mit pointerType reicht dem Fix. */
|
||||
const zeiger = (x: number, y: number, pointerType = "mouse") => {
|
||||
const e = new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true });
|
||||
Object.defineProperty(e, "pointerType", { value: pointerType });
|
||||
window.dispatchEvent(e);
|
||||
};
|
||||
const glow = () => document.querySelector("svg text[mask]") as SVGTextElement;
|
||||
const mitte = () => document.querySelector("radialGradient") as SVGRadialGradientElement;
|
||||
|
||||
it("wird als ein Bild vorgelesen, die Textknoten sind für Screenreader versteckt", () => {
|
||||
umgebung();
|
||||
render(<TextHoverEffect text="Leistungen" />);
|
||||
expect(screen.getByRole("img", { name: "Leistungen" })).toBeInTheDocument();
|
||||
const texte = [...document.querySelectorAll("svg text")];
|
||||
expect(texte).toHaveLength(3);
|
||||
for (const t of texte) expect(t).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("weckt den Glow ab etwa 180 px vor der Box und bildet den Zeiger korrekt auf die viewBox ab", () => {
|
||||
const abspielen = umgebung();
|
||||
render(<TextHoverEffect text="Leistungen" />);
|
||||
expect(glow().style.opacity).toBe("0");
|
||||
expect(glow().style.transition).toContain("opacity");
|
||||
|
||||
act(() => zeiger(700, BOX.top - 180));
|
||||
abspielen();
|
||||
expect(glow().style.opacity).toBe("1");
|
||||
expect(mitte().getAttribute("cx")).toBe("150");
|
||||
expect(mitte().getAttribute("cy")).toBe("-45");
|
||||
|
||||
act(() => zeiger(700, BOX.top - 400));
|
||||
abspielen();
|
||||
expect(glow().style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("Touch lässt den Glow nicht kleben, und Verlassen der Seite schaltet ihn aus", () => {
|
||||
const abspielen = umgebung();
|
||||
render(<TextHoverEffect text="Leistungen" />);
|
||||
act(() => zeiger(700, BOX.top + 100, "touch"));
|
||||
abspielen();
|
||||
expect(glow().style.opacity).toBe("0");
|
||||
|
||||
act(() => zeiger(700, BOX.top + 100));
|
||||
abspielen();
|
||||
expect(glow().style.opacity).toBe("1");
|
||||
act(() => {
|
||||
document.documentElement.dispatchEvent(new MouseEvent("mouseleave"));
|
||||
});
|
||||
expect(glow().style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("auf Geräten ohne feinen Zeiger (Touch) werden gar keine Zeiger-Listener angehängt", () => {
|
||||
const abspielen = umgebung(false);
|
||||
const hinzu = vi.spyOn(window, "addEventListener");
|
||||
render(<TextHoverEffect text="Leistungen" />);
|
||||
expect(hinzu.mock.calls.filter(([typ]) => typ === "pointermove")).toHaveLength(0);
|
||||
act(() => zeiger(700, BOX.top + 100));
|
||||
abspielen();
|
||||
expect(glow().style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("räumt seine Listener beim Entfernen wieder auf", () => {
|
||||
umgebung();
|
||||
const weg = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = render(<TextHoverEffect text="Leistungen" />);
|
||||
unmount();
|
||||
expect(weg.mock.calls.map(([typ]) => typ)).toEqual(expect.arrayContaining(["pointermove", "scroll"]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user