mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add shared skeleton loader primitives (#998)
This commit is contained in:
@@ -2,6 +2,16 @@
|
||||
@import "tw-animate-css";
|
||||
@config "../../../tailwind.config.js";
|
||||
|
||||
.sprout-arc-spinner {
|
||||
animation: sprout-arc-spinner-spin 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sprout-arc-spinner-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.buzz-emoji-mart {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
@@ -837,6 +847,121 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--skel-pulse-dur: 1000ms;
|
||||
--skel-pulse-count: infinite;
|
||||
--skel-pulse-min: 0.5;
|
||||
--skel-reveal-dur: 400ms;
|
||||
--skel-reveal-blur: 2px;
|
||||
--skel-reveal-ease: ease-in-out;
|
||||
}
|
||||
|
||||
.t-skel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.t-skel[data-layout="flow"] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.t-skel-skeleton,
|
||||
.t-skel-content {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.t-skel[data-layout="flow"] > .t-skel-skeleton,
|
||||
.t-skel[data-layout="flow"] > .t-skel-content {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.t-skel[data-layout="flow"] > .t-skel-skeleton {
|
||||
inset: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.t-skel[data-layout="flow"] > .t-skel-content,
|
||||
.t-skel[data-layout="flow"].is-revealed > .t-skel-skeleton {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.t-skel[data-layout="flow"].is-revealed > .t-skel-content {
|
||||
inset: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.t-skel-skeleton {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--skel-reveal-dur) var(--skel-reveal-ease),
|
||||
filter var(--skel-reveal-dur) var(--skel-reveal-ease);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.t-skel-content {
|
||||
filter: blur(var(--skel-reveal-blur));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--skel-reveal-dur) var(--skel-reveal-ease),
|
||||
filter var(--skel-reveal-dur) var(--skel-reveal-ease);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.t-skel.is-revealed .t-skel-skeleton {
|
||||
filter: blur(var(--skel-reveal-blur));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t-skel.is-revealed .t-skel-content {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.t-skel.is-resetting .t-skel-skeleton,
|
||||
.t-skel.is-resetting .t-skel-content {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.t-skel-bar.is-pulsing,
|
||||
.t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
|
||||
animation: t-skel-pulse var(--skel-pulse-dur) ease-in-out
|
||||
var(--skel-pulse-count);
|
||||
}
|
||||
|
||||
.t-skel.is-revealed .t-skel-bar.is-pulsing,
|
||||
.t-skel.is-revealed .t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes t-skel-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: var(--skel-pulse-min);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.t-skel-skeleton,
|
||||
.t-skel-content {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.t-skel-bar.is-pulsing,
|
||||
.t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.buzz-startup-shell {
|
||||
min-height: 100dvh;
|
||||
|
||||
@@ -1,15 +1,99 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
type SkeletonProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
pulsing?: boolean;
|
||||
};
|
||||
|
||||
function Skeleton({ className, pulsing = true, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"t-skel-bar rounded-md bg-primary/10",
|
||||
pulsing && "is-pulsing",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
type SkeletonRevealProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
contentClassName?: string;
|
||||
layout?: "absolute" | "flow";
|
||||
loading: boolean;
|
||||
skeleton: React.ReactNode;
|
||||
skeletonClassName?: string;
|
||||
};
|
||||
|
||||
function SkeletonReveal({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
layout = "flow",
|
||||
loading,
|
||||
skeleton,
|
||||
skeletonClassName,
|
||||
...props
|
||||
}: SkeletonRevealProps) {
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const previousLoadingRef = React.useRef(loading);
|
||||
const [isResetting, setIsResetting] = React.useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const wasLoading = previousLoadingRef.current;
|
||||
previousLoadingRef.current = loading;
|
||||
|
||||
if (!loading || wasLoading) return;
|
||||
|
||||
setIsResetting(true);
|
||||
rootRef.current?.getBoundingClientRect();
|
||||
|
||||
const reset = () => setIsResetting(false);
|
||||
const frameId = globalThis.requestAnimationFrame
|
||||
? globalThis.requestAnimationFrame(reset)
|
||||
: globalThis.setTimeout(reset, 0);
|
||||
|
||||
return () => {
|
||||
if (typeof frameId === "number") {
|
||||
if (globalThis.cancelAnimationFrame) {
|
||||
globalThis.cancelAnimationFrame(frameId);
|
||||
} else {
|
||||
globalThis.clearTimeout(frameId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"t-skel",
|
||||
!loading && "is-revealed",
|
||||
isResetting && "is-resetting",
|
||||
className,
|
||||
)}
|
||||
data-layout={layout}
|
||||
data-state={loading ? "loading" : "loaded"}
|
||||
ref={rootRef}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn("t-skel-skeleton is-pulsing", skeletonClassName)}
|
||||
>
|
||||
{skeleton}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={loading}
|
||||
className={cn("t-skel-content", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton, SkeletonReveal };
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SpinnerProps = React.ComponentPropsWithoutRef<"svg"> & {
|
||||
type SpinnerProps = React.ComponentPropsWithoutRef<"span"> & {
|
||||
className?: string;
|
||||
size?: number;
|
||||
size?: number | string;
|
||||
};
|
||||
|
||||
export function Spinner({
|
||||
children,
|
||||
className,
|
||||
size,
|
||||
role = "status",
|
||||
"aria-label": ariaLabel = "Loading",
|
||||
"aria-hidden": ariaHidden,
|
||||
style,
|
||||
...rest
|
||||
}: SpinnerProps) {
|
||||
const isDecorative = ariaHidden === true || ariaHidden === "true";
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={cn("animate-spin", className)}
|
||||
size={size}
|
||||
role={role}
|
||||
aria-label={ariaLabel}
|
||||
<span
|
||||
aria-hidden={ariaHidden}
|
||||
className={cn(
|
||||
"sprout-arc-spinner inline-block h-6 w-6 shrink-0 rounded-full border-4 border-current/10 border-t-current",
|
||||
className,
|
||||
)}
|
||||
role={isDecorative ? undefined : role}
|
||||
style={{
|
||||
...(size === undefined ? null : { height: size, width: size }),
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
{isDecorative ? null : <span className="sr-only">{ariaLabel}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user