mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Add shared skeleton loader primitives (#998)
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@config "../../../tailwind.config.js";
|
@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 {
|
.buzz-emoji-mart {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
display: flex;
|
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 {
|
@layer components {
|
||||||
.buzz-startup-shell {
|
.buzz-startup-shell {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
|||||||
@@ -1,15 +1,99 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
function Skeleton({
|
type SkeletonProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
className,
|
pulsing?: boolean;
|
||||||
...props
|
};
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
|
function Skeleton({ className, pulsing = true, ...props }: SkeletonProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
{...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";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
type SpinnerProps = React.ComponentPropsWithoutRef<"svg"> & {
|
type SpinnerProps = React.ComponentPropsWithoutRef<"span"> & {
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: number;
|
size?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Spinner({
|
export function Spinner({
|
||||||
|
children,
|
||||||
className,
|
className,
|
||||||
size,
|
size,
|
||||||
role = "status",
|
role = "status",
|
||||||
"aria-label": ariaLabel = "Loading",
|
"aria-label": ariaLabel = "Loading",
|
||||||
|
"aria-hidden": ariaHidden,
|
||||||
|
style,
|
||||||
...rest
|
...rest
|
||||||
}: SpinnerProps) {
|
}: SpinnerProps) {
|
||||||
|
const isDecorative = ariaHidden === true || ariaHidden === "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Loader2
|
<span
|
||||||
className={cn("animate-spin", className)}
|
aria-hidden={ariaHidden}
|
||||||
size={size}
|
className={cn(
|
||||||
role={role}
|
"sprout-arc-spinner inline-block h-6 w-6 shrink-0 rounded-full border-4 border-current/10 border-t-current",
|
||||||
aria-label={ariaLabel}
|
className,
|
||||||
|
)}
|
||||||
|
role={isDecorative ? undefined : role}
|
||||||
|
style={{
|
||||||
|
...(size === undefined ? null : { height: size, width: size }),
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
{isDecorative ? null : <span className="sr-only">{ariaLabel}</span>}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user