104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
"use client";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { firstAnimationListValue, parseCssTime } from "@kandev/ui/animation-utils";
|
|
import { createPersistentMotionVisibility } from "@kandev/ui/persistent-motion-visibility";
|
|
|
|
type GridSpinnerProps = {
|
|
className?: string;
|
|
ariaLabel?: string;
|
|
};
|
|
|
|
const gridKeyframes: Keyframe[] = [
|
|
{ offset: 0, transform: "scale3d(0.5, 0.5, 1)" },
|
|
{ offset: 0.35, transform: "scale3d(0, 0, 1)" },
|
|
{ offset: 0.7, transform: "scale3d(0.5, 0.5, 1)" },
|
|
{ offset: 1, transform: "scale3d(0.5, 0.5, 1)" },
|
|
];
|
|
|
|
const useCompositorEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
|
|
|
|
export function GridSpinner({ className, ariaLabel }: GridSpinnerProps) {
|
|
const { t } = useTranslation();
|
|
const gridRef = React.useRef<HTMLSpanElement>(null);
|
|
|
|
useCompositorEffect(() => {
|
|
const cubes = Array.from(
|
|
gridRef.current?.querySelectorAll<HTMLElement>(".spinner-grid-cube") ?? [],
|
|
);
|
|
const grid = gridRef.current;
|
|
if (!grid) return;
|
|
const visibility = createPersistentMotionVisibility(grid);
|
|
const registrations = cubes.map((cube) => visibility.register(cube));
|
|
const animations = startGridAnimations(cubes);
|
|
if (!animations) {
|
|
return () => {
|
|
for (const registration of registrations) registration.unregister();
|
|
visibility.dispose();
|
|
};
|
|
}
|
|
|
|
for (let index = 0; index < cubes.length; index += 1) {
|
|
registrations[index]?.setAnimation(animations[index] ?? null);
|
|
cubes[index].style.animation = "none";
|
|
}
|
|
|
|
return () => {
|
|
for (const animation of animations) animation.cancel();
|
|
for (const registration of registrations) registration.unregister();
|
|
visibility.dispose();
|
|
for (const cube of cubes) cube.style.removeProperty("animation");
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<span
|
|
ref={gridRef}
|
|
className={`spinner-grid ${className ?? ""}`}
|
|
role="status"
|
|
aria-label={ariaLabel ?? t("common:loadingIndicatorLabel")}
|
|
>
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
<span className="spinner-grid-cube" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function startGridAnimations(cubes: HTMLElement[]): Animation[] | null {
|
|
if (cubes.length !== 9 || cubes.some((cube) => typeof cube.animate !== "function")) return null;
|
|
|
|
const timing = cubes.map(readAnimationTiming);
|
|
const validTiming = timing.filter((value): value is KeyframeAnimationOptions => value !== null);
|
|
if (validTiming.length !== cubes.length) return null;
|
|
|
|
const animations: Animation[] = [];
|
|
try {
|
|
for (let index = 0; index < cubes.length; index += 1) {
|
|
animations.push(cubes[index].animate(gridKeyframes, validTiming[index]));
|
|
}
|
|
return animations;
|
|
} catch {
|
|
for (const animation of animations) animation.cancel();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readAnimationTiming(cube: HTMLElement): KeyframeAnimationOptions | null {
|
|
const computedStyle = window.getComputedStyle(cube);
|
|
if (firstAnimationListValue(computedStyle.animationName) === "none") return null;
|
|
|
|
const duration = parseCssTime(computedStyle.animationDuration);
|
|
const delay = parseCssTime(computedStyle.animationDelay);
|
|
const easing = firstAnimationListValue(computedStyle.animationTimingFunction);
|
|
if (duration === null || duration <= 0 || delay === null || !easing) return null;
|
|
|
|
return { delay, duration, easing, iterations: Infinity };
|
|
}
|