Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/governance-app-frontend/src/app/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
--sidebar-ring: var(--icp-accent-strong);
--main-content: var(--icp-bg);
--staking-ratio: var(--icp-section-teal);
/* Skeleton bars pulse between these two. The pulse animates a color, which
leaves `opacity` free for the delayed reveal. An ink wash on the parchment,
not a tint of the rust accent: a placeholder must not read as content. */
--skeleton-base: rgba(26, 26, 26, 0.07);
--skeleton-peak: rgba(26, 26, 26, 0.035);
}

.dark {
Expand Down Expand Up @@ -111,6 +116,8 @@
--sidebar-ring: var(--icp-accent-strong);
--main-content: var(--icp-bg);
--staking-ratio: #3fafaa;
--skeleton-base: rgba(162, 154, 141, 0.2);
--skeleton-peak: rgba(162, 154, 141, 0.1);
}

@theme inline {
Expand Down Expand Up @@ -204,6 +211,58 @@
}

@layer utilities {
/*
* Skeleton bars.
*
* A bar claims its space on the first frame, so the content lands without a
* jump. It stays invisible for `--skeleton-delay` first, so a fast query
* resolves into content with no grey flash on the way.
*
* The reveal animates `opacity` and the pulse animates `background-color`.
* Two animations on one property fight, and the pulse is declared last, so
* it would win and cancel the delay.
*/
@keyframes skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes skeleton-pulse {
0%,
100% {
background-color: var(--skeleton-base);
}
50% {
background-color: var(--skeleton-peak);
}
}

.skeleton {
--skeleton-delay: 120ms;
background-color: var(--skeleton-base);
animation:
skeleton-reveal 150ms ease-out var(--skeleton-delay) both,
skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) var(--skeleton-delay) infinite;
}

/*
* Keep the delayed reveal, drop the pulse. Spinners keep turning: they are
* the only sign that a mutation is still running.
*/
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: skeleton-reveal 0s linear var(--skeleton-delay) both;
}

.animate-pulse {
animation: none;
}
}

.text-hero-responsive {
font-size: clamp(2rem, 5vh, 4rem);
line-height: 1.1;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';

import { useDelayedFlag } from '@hooks/useDelayedFlag';

import { EmptyMessage } from './EmptyMessage';
import { MultipleSkeletons } from './MultipleSkeletons';
import { SkeletonScreen } from './skeletons/SkeletonScreen';
import { SkeletonText } from './skeletons/SkeletonText';
import { WarningMessage } from './WarningMessage';

type InfiniteQueryData<TData = unknown> = Partial<InfiniteData<TData, unknown>>;
Expand Down Expand Up @@ -42,11 +41,19 @@ export const QueryStates = <TData,>({
}: Props<TData>) => {
const { t } = useTranslation();
const q = query || infiniteQuery;
const showLoading = useDelayedFlag(q.isLoading);

if (q.isLoading) {
// Hold the frame empty until the delay elapses rather than flashing a skeleton.
return showLoading ? loadingComponent || <MultipleSkeletons count={3} /> : null;
// The skeleton holds the space at once and reveals itself after a short
// delay, so a fast query needs no hold-back here. See `.skeleton` in
// `main.css`. Pass a `loadingComponent` built from `Skeleton` to keep that,
// and wrap it in `SkeletonScreen` so screen readers hear "loading".
return (
loadingComponent || (
Comment thread
yhabib marked this conversation as resolved.
Outdated
<SkeletonScreen>
<SkeletonText lines={3} />
</SkeletonScreen>
)
);
}

if (q.error) {
Expand Down
14 changes: 13 additions & 1 deletion src/governance-app-frontend/src/common/components/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { cn } from '@common/utils/shadcn';

/**
* A placeholder bar.
*
* The look and the timing live in the `.skeleton` class in `main.css`: the bar
* holds its space from the first frame and stays invisible for a short delay,
* so a fast query resolves into content without a grey flash and without a
* jump. Pass size classes only; the colour comes from the theme.
*
* The bar is decorative. Wrap a group of them in `SkeletonScreen` so screen
* readers hear one "loading" for the region instead of one per bar.
*/
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-accent dark:bg-muted-foreground/20', className)}
className={cn('skeleton rounded-md', className)}
{...props}
aria-hidden={true}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';

type Props = React.ComponentProps<'div'>;

/**
* Announces one loading region.
*
* The bars inside are `aria-hidden`, so without this wrapper a screen reader
* hears nothing at all while a page loads.
*/
export const SkeletonScreen = ({ className, children, ...props }: Props) => {
const { t } = useTranslation();

return (
<div role="status" aria-label={t(($) => $.common.loading)} className={className} {...props}>
{children}
Comment thread
yhabib marked this conversation as resolved.
Outdated
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Skeleton } from '@components/Skeleton';
import { cn } from '@utils/shadcn';

type Size = 'sm' | 'base' | 'lg';

type Props = {
/** Number of bars. The last one is short, the way a paragraph ends. */
lines?: number;
/** Matches the text scale the bars stand in for. */
size?: Size;
className?: string;
};

const heights: Record<Size, string> = {
sm: 'h-3',
base: 'h-3.5',
lg: 'h-4',
};

const gaps: Record<Size, string> = {
sm: 'gap-2',
base: 'gap-2.5',
lg: 'gap-3',
};

export const SkeletonText = ({ lines = 3, size = 'base', className }: Props) => (
<div className={cn('flex flex-col', gaps[size], className)}>
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
className={cn(heights[size], index === lines - 1 && lines > 1 ? 'w-3/5' : 'w-full')}
/>
))}
</div>
);
4 changes: 0 additions & 4 deletions src/governance-app-frontend/src/common/constants/extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ export const PAGINATION_LIMIT_PROPOSALS = 25;
// Somehow this one has a different limit in case of a certified request.
export const PAGINATION_LIMIT_TRANSACTIONS = 50;

// Loading indicators are held back for this long before appearing, so a fast
// response resolves into content instead of flashing a skeleton on the way.
export const LOADING_INDICATOR_DELAY = 120;

export const E8S_PRECISION = 8;
export const E8S = 10 ** E8S_PRECISION;
export const E8Sn = BigInt(E8S);
Expand Down
34 changes: 0 additions & 34 deletions src/governance-app-frontend/src/common/hooks/useDelayedFlag.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function AccountCard() {
)}

{balanceQuery.isLoading || tickersQuery.isLoading ? (
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-20" />
) : (
<p className="text-sm text-muted-foreground">
<SensitiveValue size="sm">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const AccountsCard = () => {
{t(($) => $.accounts.title)}
</p>
{isLoading ? (
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="size-5 rounded-full" />
) : (
<Badge variant="outline">{count}</Badge>
)}
Expand All @@ -59,7 +59,7 @@ export const AccountsCard = () => {
)}

{isLoading || tickersQuery.isLoading ? (
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-20" />
) : (
<p className="text-sm text-muted-foreground">
<SensitiveValue size="sm">
Expand All @@ -75,8 +75,8 @@ export const AccountsCard = () => {
<div className="flex flex-col divide-y">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-2.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function AccountBalance({
return (
<div className="flex flex-col gap-0.5">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-20" />
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const AccountsTotalCard = () => {
</p>
)}
{isLoading || tickersQuery.isLoading ? (
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-20" />
) : (
<p className="text-sm text-muted-foreground">
<SensitiveValue size="sm">
Expand Down Expand Up @@ -96,8 +96,8 @@ export const AccountsTotalCard = () => {

{isLoading ? (
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
</div>
) : (
<div className="flex flex-wrap gap-x-4 gap-y-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,19 @@ export const DailyRewardsCard = () => {
)}
</p>
)}
{isRewardEventLoading ? (
<Skeleton className="mt-1 h-4 w-32" />
) : (
nonNullish(lastRewardTimestamp) && (
<p className="mt-1 text-sm font-normal text-muted-foreground">
{t(($) => $.home.lastRewardEvent, {
date: secondsToDate(Number(lastRewardTimestamp)),
})}
</p>
)
)}
<div className="mt-1 min-h-5">
{isRewardEventLoading ? (
<Skeleton className="h-5 w-32" />
) : (
nonNullish(lastRewardTimestamp) && (
<p className="text-sm font-normal text-muted-foreground">
{t(($) => $.home.lastRewardEvent, {
date: secondsToDate(Number(lastRewardTimestamp)),
})}
</p>
)
)}
</div>
</CardContent>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,28 @@ export const IcpPriceCard = () => {
{t(($) => $.home.icpPrice)}
</p>
{tickersQuery.isLoading ? (
<>
<Skeleton className="mb-2 h-8 w-24" />
<Skeleton className="h-4 w-20" />
</>
<Skeleton className="h-8 w-24" />
) : (
<>
<p className="text-2xl font-semibold text-foreground">
{icpPriceUsd ? `$${icpPriceUsd}` : '—'}
</p>
{nonNullish(change) && (
<p className="text-2xl font-semibold text-foreground">
{icpPriceUsd ? `$${icpPriceUsd}` : '—'}
</p>
)}
{/* The row keeps its height with or without a change, so the card does
not shrink when the skeleton leaves. */}
<div className="mt-1 min-h-5">
{tickersQuery.isLoading ? (
<Skeleton className="h-5 w-20" />
) : (
nonNullish(change) && (
<p
className={`mt-1 flex items-center gap-1 text-sm font-medium ${isPositive ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}`}
className={`flex items-center gap-1 text-sm font-medium ${isPositive ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}`}
>
<span>{isPositive ? '▲' : '▼'}</span>
{formatPercentage(Math.abs(change))} ({t(($) => $.home.icpPrice24h)})
</p>
)}
</>
)}
)
)}
</div>
</CardContent>
</Card>
);
Expand Down
Loading
Loading