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";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useCallback, useId } from "react";
|
import { useRef, useState, useEffect, useId } from "react";
|
||||||
import { motion } from "motion/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 = ({
|
export const TextHoverEffect = ({
|
||||||
text,
|
text,
|
||||||
}: {
|
}: {
|
||||||
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
|
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const gradientId = `textGradient-${id}`;
|
const gradientId = `textGradient-${id}`;
|
||||||
const revealMaskId = `revealMask-${id}`;
|
const revealMaskId = `revealMask-${id}`;
|
||||||
const textMaskId = `textMask-${id}`;
|
const textMaskId = `textMask-${id}`;
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
useEffect(() => {
|
||||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
const svg = svgRef.current;
|
||||||
if (!svgRef.current) return;
|
// Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
|
||||||
const rect = svgRef.current.getBoundingClientRect();
|
if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
||||||
// Map screen coords to viewBox coords (0 0 300 60)
|
|
||||||
const x = ((e.clientX - rect.left) / rect.width) * 300;
|
let last: { x: number; y: number } | null = null;
|
||||||
const y = ((e.clientY - rect.top) / rect.height) * 60;
|
let raf = 0;
|
||||||
setCursorPos({ x, y });
|
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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
|
|||||||
height="100%"
|
height="100%"
|
||||||
viewBox="0 0 300 60"
|
viewBox="0 0 300 60"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
onMouseEnter={() => setHovered(true)}
|
role="img"
|
||||||
onMouseLeave={() => setHovered(false)}
|
aria-label={text}
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
className="select-none"
|
className="select-none"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
|
|||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<mask id={textMaskId}>
|
<mask id={textMaskId}>
|
||||||
<rect
|
<rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="300"
|
|
||||||
height="60"
|
|
||||||
fill={hovered ? `url(#${revealMaskId})` : "black"}
|
|
||||||
/>
|
|
||||||
</mask>
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
|
|||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
strokeWidth="0.3"
|
strokeWidth="0.3"
|
||||||
|
aria-hidden="true"
|
||||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||||
style={{ opacity: 0.15, fontSize: "2rem" }}
|
style={{ opacity: 0.15, fontSize: "2rem" }}
|
||||||
>
|
>
|
||||||
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
|
|||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
strokeWidth="0.3"
|
strokeWidth="0.3"
|
||||||
|
aria-hidden="true"
|
||||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||||
style={{ fontSize: "2rem" }}
|
style={{ fontSize: "2rem" }}
|
||||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||||
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
|
|||||||
{text}
|
{text}
|
||||||
</motion.text>
|
</motion.text>
|
||||||
|
|
||||||
{/* Colored gradient revealed on hover */}
|
{/* Colored gradient revealed near the pointer */}
|
||||||
<text
|
<text
|
||||||
x="150"
|
x="150"
|
||||||
y="30"
|
y="30"
|
||||||
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
|
|||||||
stroke={`url(#${gradientId})`}
|
stroke={`url(#${gradientId})`}
|
||||||
strokeWidth="0.3"
|
strokeWidth="0.3"
|
||||||
mask={`url(#${textMaskId})`}
|
mask={`url(#${textMaskId})`}
|
||||||
|
aria-hidden="true"
|
||||||
className="font-display font-bold fill-transparent"
|
className="font-display font-bold fill-transparent"
|
||||||
style={{ fontSize: "2rem" }}
|
style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</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