From c871da816c223de69a6e256a523a1003ee807464 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 18 Sep 2025 11:41:48 -0700 Subject: [PATCH] chore: break logic into dedicated packages --- src/App.tsx | 12 +++-- src/components/card.tsx | 12 ++--- src/lib/core.tsx | 117 ++++++++++++++++++++++++++++++++++++++++ src/lib/react.tsx | 43 +++++++++++++++ src/lib/skeleton.tsx | 112 -------------------------------------- src/skeleton.css | 52 +++++++++--------- 6 files changed, 198 insertions(+), 150 deletions(-) create mode 100644 src/lib/core.tsx create mode 100644 src/lib/react.tsx delete mode 100644 src/lib/skeleton.tsx diff --git a/src/App.tsx b/src/App.tsx index 67cced2..86f8a96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,16 @@ import { Card } from "./components/card" -import { SkeletonProvider } from "./lib/skeleton" +import { useSkeletonSetup } from "./lib/react" import "./skeleton.css" function App() { + useSkeletonSetup(); + return ( - - - +
+ +
+
+
) } diff --git a/src/components/card.tsx b/src/components/card.tsx index a185519..bfb9619 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -1,4 +1,4 @@ -import { useSkeleton } from "../lib/skeleton"; +import { useSkeleton } from "../lib/react"; import styles from "./card.module.css"; export function Card() { @@ -9,12 +9,12 @@ export function Card() { const lineFour = useSkeleton(); return
-
+
-
-
-
-
+
+
+
+
} \ No newline at end of file diff --git a/src/lib/core.tsx b/src/lib/core.tsx new file mode 100644 index 0000000..356181a --- /dev/null +++ b/src/lib/core.tsx @@ -0,0 +1,117 @@ +import { Effect, Store } from "@tanstack/store"; + +export const percentageStore = new Store(0); +const renderedComponents = new Store(0); +const animationDurationStore = new Store(2000); // in milliseconds + + +export interface SkeletonOptions { + animationSpeed?: number; // pixels per second (default: 400) + highlightSize?: number; // in pixels (default: 40) + highlightColor?: string; // CSS color value + baseColor?: string; // CSS color value +} + +const defaultOptions: SkeletonOptions = { + animationSpeed: 400, + highlightSize: 40, + highlightColor: "#f5f5f5", + baseColor: "#ebebeb", +} + +const optionsStore = new Store(defaultOptions as Required); + +export function addComponent() { + renderedComponents.setState((count) => count + 1); +} + +export function removeComponent() { + renderedComponents.setState((count) => Math.max(0, count - 1)); +} + +function setAnimationDuration(duration: number) { + animationDurationStore.setState(duration); +} + +export function setOptions(options: SkeletonOptions) { + optionsStore.setState(() => { + const fullOptions = Object.entries(options).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key as keyof SkeletonOptions] = value; + } + return acc; + }, {} as Partial); + if (fullOptions.highlightSize) { document.body.style.setProperty("--skeleton-highlight-size", `${fullOptions.highlightSize}px`); } + if (fullOptions.highlightColor) { document.body.style.setProperty("--skeleton-highlight-color", fullOptions.highlightColor); } + if (fullOptions.baseColor) { document.body.style.setProperty("--skeleton-base-color", fullOptions.baseColor); } + const newOptions = { ...defaultOptions, ...fullOptions } as Required; + return newOptions; + }); +} + +let frameId: number; +export const timingEffect = new Effect({ + deps: [renderedComponents, animationDurationStore], + fn: () => { + if (frameId !== undefined) { + cancelAnimationFrame(frameId) + } + const componentCount = renderedComponents.state; + const animationDuration = animationDurationStore.state; + + if (componentCount === 0) { + return + } + + // Count to 100 in `animationDuration` milliseconds, updating the percentage store + // Loop every `animationDuration` milliseconds so that the percentage goes from 0 to 100 repeatedly + let start: number | null = null + + const step = (timestamp: number) => { + if (!start) start = timestamp + const elapsed = timestamp - start + const progress = (elapsed % animationDuration) / animationDuration + percentageStore.setState(() => progress * 100) + frameId = requestAnimationFrame(step) + } + + frameId = requestAnimationFrame(step) + }, + eager: true +}) + +const setWindowSize = () => { + const windowWidth = window.innerWidth + document.body.style.setProperty("--skeleton-window-width", `${windowWidth}px`) + // Calculate duration based on window width and speed: distance / speed * 1000 (to convert to ms) + // We add highlight size to the distance to account for the full travel + const distance = windowWidth + optionsStore.state.highlightSize + const calculatedDuration = (distance / optionsStore.state.animationSpeed) * 1000 + setAnimationDuration(calculatedDuration) +} + +export const windowSizeEffect = new Effect({ + deps: [optionsStore], + fn: () => { + window.removeEventListener("resize", setWindowSize) + setWindowSize(); + window.addEventListener("resize", setWindowSize) + }, + eager: true +}) + +export function getSkeletonObserver(ref: HTMLElement | null) { + if (!ref) return + const observer = new ResizeObserver((entries) => { + const rect = ref.getBoundingClientRect() + ref.style.setProperty("--skeleton-left", `${rect.left}px`) + for (let entry of entries) { + const width = entry.contentRect.width + ref.style.setProperty("--skeleton-width", `${width}px`) + } + }) + observer.observe(ref) + return () => { + observer.disconnect() + } +} \ No newline at end of file diff --git a/src/lib/react.tsx b/src/lib/react.tsx new file mode 100644 index 0000000..711e24b --- /dev/null +++ b/src/lib/react.tsx @@ -0,0 +1,43 @@ +import { useLayoutEffect, useState } from "react" +import { useStore } from "@tanstack/react-store"; +import { addComponent, getSkeletonObserver, percentageStore, removeComponent, setOptions, timingEffect, windowSizeEffect, type SkeletonOptions } from "./core"; + +export function useSkeletonSetup(options?: SkeletonOptions) { + useLayoutEffect(() => { + if (!options) return; + setOptions(options) + }, [options]) + + useLayoutEffect(() => { + return windowSizeEffect.mount(); + }, []); + + useLayoutEffect(() => { + return timingEffect.mount(); + }, []); +} + +export const useSkeleton = () => { + const [ref, setRef] = useState(null) + + const percentageVal = useStore(percentageStore) + + useLayoutEffect(() => { + if (!ref) return + ref.style.setProperty("--skeleton-percentage", `${percentageVal}`) + }, [ref, percentageVal]) + + useLayoutEffect(() => { + if (!ref) return + addComponent() + return () => { + removeComponent() + } + }, [ref]) + + useLayoutEffect(() => { + return getSkeletonObserver(ref); + }, [ref]) + + return setRef +} diff --git a/src/lib/skeleton.tsx b/src/lib/skeleton.tsx deleted file mode 100644 index 97d2421..0000000 --- a/src/lib/skeleton.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { createContext, useContext, useLayoutEffect, useState, type PropsWithChildren } from "react" -import { Store } from "@tanstack/store"; -import { useStore } from "@tanstack/react-store"; - -const SkeletonContext = createContext({ - componentCount: 0, - addComponent: () => { }, - removeComponent: () => { } -}) - -const percentage = new Store(0); - -interface SkeletonProviderProps { - animationSpeed?: number; // pixels per second (default: 400) -} - -export function SkeletonProvider({ children, animationSpeed: propsSpeed }: PropsWithChildren) { - const [componentCount, setComponentCount] = useState(0) - const [animationDuration, setAnimationDuration] = useState(2000) - - const speed = propsSpeed ?? 400 // pixels per second - - const addComponent = () => setComponentCount(count => count + 1) - const removeComponent = () => setComponentCount(count => Math.max(0, count - 1)) - - useLayoutEffect(() => { - if (componentCount === 0) { - return - } - - // Count to 100 in `animationDuration` milliseconds, updating the percentage store - // Loop every `animationDuration` milliseconds so that the percentage goes from 0 to 100 repeatedly - let start: number | null = null - let frameId: number - - const step = (timestamp: number) => { - if (!start) start = timestamp - const elapsed = timestamp - start - const progress = (elapsed % animationDuration) / animationDuration - percentage.setState(() => progress * 100) - frameId = requestAnimationFrame(step) - } - - frameId = requestAnimationFrame(step) - return () => { - cancelAnimationFrame(frameId) - } - }, [componentCount, animationDuration]) - - useLayoutEffect(() => { - const setWindowSize = () => { - const windowWidth = window.innerWidth - document.body.style.setProperty("--skeleton-window-width", `${windowWidth}px`) - // Calculate duration based on window width and speed: distance / speed * 1000 (to convert to ms) - // We add highlight size to the distance to account for the full travel - const distance = windowWidth + 40 // 40px is the default highlight size - const calculatedDuration = (distance / speed) * 1000 - setAnimationDuration(calculatedDuration) - } - setWindowSize() - window.addEventListener("resize", setWindowSize) - return () => { - window.removeEventListener("resize", setWindowSize) - } - }, [speed]) - - return ( - - {children} - - ) -} - -export const useSkeleton = () => { - const context = useContext(SkeletonContext) - - const [ref, setRef] = useState(null) - - const percentageVal = useStore(percentage) - - useLayoutEffect(() => { - if (!ref) return - ref.style.setProperty("--skeleton-percentage", `${percentageVal}`) - }, [ref, percentageVal]) - - useLayoutEffect(() => { - if (!ref) return - context.addComponent() - return () => { - context.removeComponent() - } - }, [ref, context]) - - // Get the element's `left` position on the page and set it as a CSS variable; change it using a ResizeObserver - useLayoutEffect(() => { - if (!ref) return - const observer = new ResizeObserver((entries) => { - const rect = ref.getBoundingClientRect() - ref.style.setProperty("--skeleton-left", `${rect.left}px`) - for (let entry of entries) { - const width = entry.contentRect.width - ref.style.setProperty("--skeleton-width", `${width}px`) - } - }) - observer.observe(ref) - return () => { - observer.disconnect() - } - }, [ref]) - - return setRef -} diff --git a/src/skeleton.css b/src/skeleton.css index f71ad5d..ee18c2f 100644 --- a/src/skeleton.css +++ b/src/skeleton.css @@ -1,43 +1,39 @@ -.react-loading-skeleton { - --base-color: #ebebeb; - --highlight-color: #f5f5f5; - --highlight-size: 40px; - --pseudo-element-display: block; /* Enable animation */ - - background-color: var(--base-color); +:root { + --skeleton-base-color: #ebebeb; + --skeleton-highlight-color: #f5f5f5; + --skeleton-highlight-size: 40px; + --skeleton-pseudo-element-display: block; + /* Replaced by JS */ + --skeleton-window-width: 0px; + --skeleton-left: 0px; + --skeleton-percentage: 0; +} +.loading-skeleton { + background-color: var(--skeleton-base-color); position: relative; overflow: hidden; } -/* Should start at `0%` of the element's width (with the highlight hidden) and make it's way to 100% of the element's width based on the time it would take to get to the other end of the screen based on `skeleton-window-width` */ -.react-loading-skeleton::after { +.loading-skeleton::after { content: ' '; - display: var(--pseudo-element-display); + display: var(--skeleton-pseudo-element-display); position: absolute; top: 0; - left: calc(0px - var(--highlight-size)); - width: var(--highlight-size); + left: calc(0px - var(--skeleton-highlight-size)); + width: var(--skeleton-highlight-size); height: 100%; background-repeat: no-repeat; - background-image: var( - --custom-highlight-background, - linear-gradient( - 90deg, - var(--base-color) 0%, - var(--highlight-color) 50%, - var(--base-color) 100% - ) - ); - transform: translateX( - calc( - (var(--skeleton-window-width) + var(--highlight-size)) * calc(var(--skeleton-percentage) / 100) - var(--skeleton-left) - ) - ); + background-image: + linear-gradient(90deg, + transparent 0%, + var(--skeleton-highlight-color) 50%, + transparent 100%); + transform: translateX(calc((var(--skeleton-window-width) + var(--skeleton-highlight-size)) * calc(var(--skeleton-percentage) / 100) - var(--skeleton-left))); } @media (prefers-reduced-motion) { - .react-loading-skeleton { - --pseudo-element-display: none; /* Disable animation */ + .loading-skeleton { + --skeleton-pseudo-element-display: none; } } \ No newline at end of file -- 2.51.2