mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 20:19:49 +02:00
feat: skeleton
This commit is contained in:
1 parent
f7c6fdd861
commit
b50c47b744
11 files changed
+88
-28
No files matched your search
@@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
|
||||||
|
&.skeleton {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
@@ -31,5 +38,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { literals } from "@blitzkit/i18n";
|
import { literals } from "@blitzkit/i18n";
|
||||||
import { useStrings } from "../../hooks/useStrings";
|
import { useStrings } from "../../hooks/useStrings";
|
||||||
import type { Avatar } from "../../protos/avatar";
|
import type { Avatar } from "../../protos/avatar";
|
||||||
import type { SkeletonProps } from "../../types/SkeletonProps";
|
import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
|
||||||
import { Badge } from "../Badge";
|
import { Badge } from "../Badge";
|
||||||
|
import { InlineSkeleton } from "../InlineSkeleton";
|
||||||
|
import { Skeleton } from "../Skeleton";
|
||||||
import { Text } from "../Text";
|
import { Text } from "../Text";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -14,13 +16,13 @@ interface Props {
|
|||||||
const MAX_CARDS = 3;
|
const MAX_CARDS = 3;
|
||||||
const CARD_TRIM = "var(--space-3)";
|
const CARD_TRIM = "var(--space-3)";
|
||||||
|
|
||||||
export function AvatarCard(props: SkeletonProps<Props>) {
|
export function AvatarCard(props: PropsWithSkeleton<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className="avatar-card">
|
<div className="avatar-card">
|
||||||
{!props.skeleton && <Series avatars={props.avatars} />}
|
<Series skeleton={props.skeleton} {...props} />
|
||||||
|
|
||||||
<Text align="center" className="avatar-label">
|
<Text align="center" className="avatar-label">
|
||||||
{props.skeleton ? "LOADING" : props.name}
|
{props.skeleton ? <InlineSkeleton /> : props.name}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -30,32 +32,35 @@ interface SeriesProps {
|
|||||||
avatars: Avatar[];
|
avatars: Avatar[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Series({ avatars }: SeriesProps) {
|
function Series(props: PropsWithSkeleton<SeriesProps>) {
|
||||||
const count = Math.min(avatars.length, MAX_CARDS);
|
const count = Math.min(props.skeleton ? 0 : props.avatars.length, MAX_CARDS);
|
||||||
const strings = useStrings();
|
const strings = useStrings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="avatar-series">
|
<div className="avatar-series">
|
||||||
{avatars.slice(-MAX_CARDS).map((avatar, index) => (
|
{!props.skeleton &&
|
||||||
<div
|
props.avatars.slice(-MAX_CARDS).map((avatar, index) => (
|
||||||
key={avatar.name}
|
<div
|
||||||
className="avatar-entry"
|
key={avatar.name}
|
||||||
style={{
|
className="avatar-entry"
|
||||||
filter: `brightness(${(index + 1) / count})`,
|
style={{
|
||||||
top: `calc(${index} * ${CARD_TRIM})`,
|
filter: `brightness(${(index + 1) / count})`,
|
||||||
left: `calc(${index} * ${CARD_TRIM})`,
|
top: `calc(${index} * ${CARD_TRIM})`,
|
||||||
width: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
|
left: `calc(${index} * ${CARD_TRIM})`,
|
||||||
height: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
|
width: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
|
||||||
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
|
height: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
|
||||||
}}
|
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{avatars.length > 1 && (
|
{!props.skeleton && props.avatars.length > 1 && (
|
||||||
<Badge color="gray" highContrast className="count">
|
<Badge color="gray" highContrast className="count">
|
||||||
{literals(strings.units.x, { value: avatars.length })}
|
{literals(strings.units.x, { value: props.avatars.length })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{props.skeleton && <Skeleton className="avatar-entry" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
.avatars-list {
|
.avatars-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
|
||||||
gap: var(--space-5) var(--space-4);
|
gap: var(--space-5) var(--space-4);
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ export function AvatarsList() {
|
|||||||
return (
|
return (
|
||||||
<div className="avatars-list">
|
<div className="avatars-list">
|
||||||
<IncrementalLoader
|
<IncrementalLoader
|
||||||
initial={40}
|
initial={9 * 5}
|
||||||
intermediate={10}
|
intermediate={9 * 3}
|
||||||
data={filtered}
|
data={filtered}
|
||||||
Component={AvatarCard}
|
Component={AvatarCard}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -80,5 +80,6 @@ import "@fontsource-variable/roboto-flex";
|
|||||||
--blur-tint: blur(8rem);
|
--blur-tint: blur(8rem);
|
||||||
|
|
||||||
--duration-brief: 200ms;
|
--duration-brief: 200ms;
|
||||||
|
--duration-subtle: 2s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { times } from "lodash-es";
|
import { times } from "lodash-es";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { SkeletonProps } from "../../types/SkeletonProps";
|
import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export interface IncrementalLoaderProps<Props extends { key: string }> {
|
export interface IncrementalLoaderProps<Props extends { key: string }> {
|
||||||
@@ -10,7 +10,7 @@ export interface IncrementalLoaderProps<Props extends { key: string }> {
|
|||||||
|
|
||||||
skeleton?: boolean;
|
skeleton?: boolean;
|
||||||
data: Props[];
|
data: Props[];
|
||||||
Component: (props: SkeletonProps<Omit<Props, "key">>) => ReactNode;
|
Component: (props: PropsWithSkeleton<Omit<Props, "key">>) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IncrementalLoader<Props extends { key: string }>({
|
export function IncrementalLoader<Props extends { key: string }>({
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.inline-skeleton {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { classNames } from "../../core/ui/classNames";
|
||||||
|
import { Skeleton, type SkeletonProps } from "../Skeleton";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export interface InlineSkeletonProps extends SkeletonProps {}
|
||||||
|
|
||||||
|
export function InlineSkeleton({ className, ...props }: InlineSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<Skeleton className={classNames("inline-skeleton", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@keyframes skeleton-animation {
|
||||||
|
0% {
|
||||||
|
background-color: var(--black-a8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: var(--black-a5);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: var(--black-a8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
animation: skeleton-animation var(--duration-subtle) ease infinite;
|
||||||
|
|
||||||
|
border-radius: var(--space-2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { classNames } from "../../core/ui/classNames";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export interface SkeletonProps extends ComponentProps<"div"> {}
|
||||||
|
|
||||||
|
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||||
|
return <div className={classNames("skeleton", className)} {...props} />;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
export type SkeletonProps<Props> =
|
export type PropsWithSkeleton<Props> =
|
||||||
| { skeleton: true }
|
| { skeleton: true }
|
||||||
| (Props & { skeleton?: false });
|
| (Props & { skeleton?: false });
|
||||||
Reference in new issue
Block a user