UI: Implement Game Title Scroll on Overflow (#38)

* UI: Implement Game Title Scroll on Overflow

* Address Review Comment

* Refactor the game title scroll to a component.

* Address Review Comments

* Address Review Comments x3

* Address Review Comments x4

* Linter Fixes

* Remove unused reference
This commit is contained in:
Exhigh
2025-12-14 19:27:51 +04:00
committed by GitHub
parent e74da889f9
commit 6653260bf8
3 changed files with 102 additions and 4 deletions

View File

@@ -139,3 +139,27 @@
@apply outline-solid outline-1 outline-current;
}
}
.marquee-text-track {
display: flex;
padding-left: 5rem;
gap: 5rem;
width: max-content;
animation: marquee-move-text 5s linear infinite forwards;
}
@keyframes marquee-move-text {
to {
transform: translateX(-49.85%);
}
}
.fadeout-horizontal {
mask-image: linear-gradient(
to right,
transparent,
black 1rem,
black calc(100% - 1rem),
transparent
);
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
interface MarqueeTitleProps {
classNames: string;
title: string;
}
export function MarqueeTitle(props: MarqueeTitleProps) {
const [titleAnimate, setTitleAnimate] = useState<boolean>(false);
const [boxWidth, setBoxWidth] = useState(150);
const titleDivRef = useRef<HTMLDivElement>(null);
const titleSpanRef = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const handleResize = () => {
const width = titleDivRef.current?.getBoundingClientRect().width;
setBoxWidth(width ?? 150);
};
if (titleDivRef.current) {
const width = titleDivRef.current.getBoundingClientRect().width;
setBoxWidth(width);
window.addEventListener("resize", handleResize);
}
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
function textWrapAnimate() {
if (!titleSpanRef.current || !titleDivRef.current) {
return false;
}
if (props.title.length < 17) {
return false;
} else {
const boxLength = Math.ceil(boxWidth);
if (boxLength > 170 && props.title.length < 19) {
return false;
}
return true;
}
}
setTitleAnimate(textWrapAnimate());
}, [boxWidth, props.title]);
return (
<div
className={
`${titleAnimate ? "fadeout-horizontal" + " " : ""}` +
"col-span-full row-start-1 row-end-2 flex-row px-2 py-2 text-center"
}
ref={titleDivRef}
>
<span
className={
`${titleAnimate ? "marquee-text-track" + " " : ""}` +
`${props.classNames}`
}
ref={titleSpanRef}
>
<p>{props.title}</p>
<p aria-hidden="true" hidden={!titleAnimate}>
{props.title}
</p>
</span>
</div>
);
}

View File

@@ -34,6 +34,7 @@ import { stringifyError } from "@/lib/utils/error";
import { cn } from "@/lib/utils/ui";
import type { GameEntry } from "@/store/db";
import { gamepadActiveAtom } from "@/store/gamepad";
import { MarqueeTitle } from "./animate-ui/effects/marquee-title";
import { GameBoxCover } from "./game-cover";
import GamepadIcon, { ButtonType } from "./gamepad-icon";
import { GameDetailsModal } from "./modals/game-details-modal";
@@ -222,10 +223,12 @@ export function GameBox({ game }: { game: GameEntry; isFirst?: boolean }) {
)}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 bg-black/50 opacity-0 backdrop-blur-[2px] transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 group-data-gamepad-focus:opacity-100">
<span className="col-span-full row-start-1 row-end-2 truncate px-3 py-2 text-center font-semibold text-lg">
{/* TODO: scroll text on overflow */}
{game.title}
</span>
<MarqueeTitle
classNames={
"inline-block text-nowrap text-center font-semibold text-lg"
}
title={game.title}
/>
<div className="col-start-1 col-end-4 row-start-3 row-end-4 m-2 flex h-8 justify-between self-end">
<Button