Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 6 additions & 5 deletions src/components/LandingCommunitySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
import type { LibraryId } from '~/libraries'
import { MaintainersSection } from './MaintainersSection'
import { Skeleton, SkeletonGroup } from '~/components/ds/ui'

interface LandingCommunitySectionProps {
libraryId: LibraryId
Expand All @@ -10,17 +11,17 @@ interface LandingCommunitySectionProps {
function SectionSkeleton({ title }: { title: string }) {
return (
<div className="px-4 w-full lg:max-w-(--breakpoint-lg) md:mx-auto">
<div className="space-y-8">
<div className="h-10 w-48 rounded bg-gray-200/70 dark:bg-gray-800/70 animate-pulse" />
<SkeletonGroup label={`Loading ${title}`} className="space-y-8">
<Skeleton className="h-10 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, index) => (
<div
<Skeleton
key={`${title}-${index}`}
className="aspect-square rounded-lg bg-gray-200/70 dark:bg-gray-800/70 animate-pulse"
className="aspect-square rounded-lg"
/>
))}
</div>
</div>
</SkeletonGroup>
</div>
)
}
Expand Down
62 changes: 62 additions & 0 deletions src/components/PartnersGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PartnerImage,
partnerTierBandColor,
partnerTierLabels,
partnerTiers,
type PartnerTier,
} from '~/utils/partners'
import {
Expand All @@ -13,6 +14,7 @@ import {
type PartnerPlacementContext,
} from '~/utils/partner-placement'
import { usePartnerPlacementContext } from '~/utils/usePartnerPlacementContext'
import { Skeleton } from '~/components/ds/ui'
import {
trackEvent,
useTrackedImpression,
Expand Down Expand Up @@ -155,6 +157,66 @@ function PartnerGridItem({
)
}

/**
* Loading shape for `PartnersGrid`. It walks the same tier config as the real
* grid, so the band heights and cell sizes line up instead of collapsing to a
* uniform placeholder.
*/
export function PartnersGridSkeleton({
partnersList,
status = 'active',
withSponsorsBand = true,
}: {
partnersList?: PartnersGridProps['partnersList']
status?: PartnersGridProps['status']
withSponsorsBand?: boolean
}) {
const items = (partnersList ?? allPartners).filter(
(partner) => partner.status === status,
)

return (
<div className="overflow-hidden rounded-2xl corner-squircle border border-gray-200 dark:border-gray-800">
{partnerTiers.map((tier) => {
const count = items.filter((partner) => partner.tier === tier).length
if (count === 0) {
return null
}
const sizing = tierLayout[tier]
return (
<React.Fragment key={tier}>
<div className="h-[60px]" aria-hidden="true">
<Skeleton className="h-full rounded-none" />
</div>
<div className="flex flex-wrap items-stretch -mr-px -mb-px">
{Array.from({ length: count }).map((_, index) => (
<div
key={`${tier}-${index}`}
className={`${sizing.flexBasis} ${sizing.minHeight} border-r border-b border-gray-200 dark:border-gray-800`}
>
<Skeleton className="h-full rounded-none" />
</div>
))}
</div>
</React.Fragment>
)
})}
{withSponsorsBand ? (
<>
<div className="h-[60px]" aria-hidden="true">
<Skeleton className="h-full rounded-none" />
</div>
<div className="px-4 py-10">
<Skeleton className="h-[420px] rounded-lg sm:h-[480px] lg:h-[540px]" />
<Skeleton className="mx-auto mt-8 h-12 w-full max-w-(--breakpoint-sm)" />
<Skeleton className="mx-auto mt-6 h-10 w-48" />
</div>
</>
) : null}
</div>
)
}

export function PartnersGrid({
analyticsPlacement = 'grid',
partnersList,
Expand Down
39 changes: 11 additions & 28 deletions src/components/PartnersSponsorsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { Link } from '@tanstack/react-router'
import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
import { ArrowRightIcon } from '@phosphor-icons/react/ArrowRight'
import { Button } from '~/components/ds/ui'
import { Button, Skeleton, SkeletonGroup } from '~/components/ds/ui'
import { OssSponsorsWithQuery } from '~/components/OssSponsorsSection'
import { PartnersGrid, TierBand } from '~/components/PartnersGrid'
import {
PartnersGrid,
PartnersGridSkeleton,
TierBand,
} from '~/components/PartnersGrid'
import type { PartnerPlacement } from '~/utils/analytics'

type PartnersSponsorsContentProps = {
Expand Down Expand Up @@ -74,34 +78,13 @@ export function PartnersSponsorsContent({

function PartnersSponsorsSkeleton() {
return (
<div
aria-hidden="true"
<SkeletonGroup
label="Loading partners"
className="px-4 lg:max-w-(--breakpoint-lg) md:mx-auto"
>
<div className="mb-6 h-10 w-40 animate-pulse rounded bg-gray-200/70 dark:bg-gray-800/70" />
<div className="overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
{[2, 3].map((columns, band) => (
<div key={`partner-band-${band}`}>
<div className="h-12 animate-pulse bg-gray-200/70 dark:bg-gray-800/70" />
<div
className="grid gap-px bg-gray-200/70 dark:bg-gray-800/70"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: columns * 2 }).map((_, index) => (
<div
key={`partner-skeleton-${band}-${index}`}
className="min-h-[130px] animate-pulse bg-white/70 dark:bg-gray-950/60"
/>
))}
</div>
</div>
))}
<div className="h-12 animate-pulse bg-gray-200/70 dark:bg-gray-800/70" />
<div className="h-[420px] animate-pulse bg-white/70 sm:h-[480px] lg:h-[540px] dark:bg-gray-950/60" />
</div>
</div>
<Skeleton className="mb-6 h-10 w-40" />
<PartnersGridSkeleton />
</SkeletonGroup>
)
}

Expand Down
12 changes: 8 additions & 4 deletions src/components/RecentPostsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchRecentPosts, type RecentPost } from '~/utils/blog.functions'
import { formatPublishedDate } from '~/utils/blog-format'
import { Skeleton, SkeletonGroup } from '~/components/ds/ui'

type RecentPostsWidgetProps = {
posts?: ReadonlyArray<RecentPost>
Expand Down Expand Up @@ -76,14 +77,17 @@ function RecentPostsSkeleton() {
Latest Posts
</Link>
</div>
<div className="flex flex-col divide-y divide-gray-500/10">
<SkeletonGroup
label="Loading latest posts"
className="flex flex-col divide-y divide-gray-500/10"
>
{Array.from({ length: 3 }).map((_, index) => (
<div key={`recent-post-skeleton-${index}`} className="px-3 py-2.5">
<div className="h-3.5 w-full rounded bg-gray-200/70 dark:bg-gray-800/70" />
<div className="mt-2 h-2.5 w-20 rounded bg-gray-100 dark:bg-gray-900" />
<Skeleton className="h-3.5 w-full" />
<Skeleton className="mt-2 h-2.5 w-20" />
</div>
))}
</div>
</SkeletonGroup>
</div>
)
}
Expand Down
27 changes: 19 additions & 8 deletions src/components/ShowcaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ThumbsDownIcon,
CodeIcon,
} from '@phosphor-icons/react'
import { Skeleton } from '~/components/ds/ui'
import { libraries, type LibraryId } from '~/libraries'
import type { PublicShowcase } from '~/db/types'

Expand Down Expand Up @@ -193,14 +194,24 @@ export function ShowcaseCard({

export function ShowcaseCardSkeleton() {
return (
<div className="rounded-xl corner-squircle overflow-hidden bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 animate-pulse">
<div className="aspect-video bg-gray-200 dark:bg-gray-700" />
<div className="p-4">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full mt-2" />
<div className="flex gap-1.5 mt-3">
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-5 w-14 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="group relative flex h-full flex-col overflow-hidden rounded-xl corner-squircle border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
<Skeleton className="aspect-video rounded-none" />
<div className="flex-1 p-4">
<Skeleton className="h-7 w-3/4" />
<Skeleton className="mt-1 h-5 w-full" />
<Skeleton className="mt-1 h-5 w-2/3" />
</div>
<div className="mt-auto px-4 pb-4">
<div className="flex items-end justify-between gap-2">
<div className="flex min-w-0 flex-wrap gap-1.5">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
<div className="flex shrink-0 items-center gap-1">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-6" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
</div>
</div>
Expand Down
17 changes: 13 additions & 4 deletions src/components/ShowcaseGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMyShowcaseVotesQueryOptions,
} from '~/queries/showcases'
import { voteShowcase } from '~/utils/showcase.functions'
import { Skeleton, SkeletonGroup } from '~/components/ds/ui'
import { ShowcaseCard, ShowcaseCardSkeleton } from './ShowcaseCard'
import { CommunityProjectCard } from './CommunityProjectCard'
import { SubmitShowcasePlaceholder } from './ShowcaseSection'
Expand Down Expand Up @@ -380,18 +381,26 @@ export function ShowcaseGallery({
{/* Grid */}
<div className="max-w-7xl mx-auto px-4 py-8">
{isLoading ? (
<div className={gridClassName}>
<SkeletonGroup label="Loading projects" className={gridClassName}>
{Array.from({ length: 6 }).map((_, i) =>
community ? (
<div
key={i}
className="h-32 animate-pulse border-b border-gray-200 dark:border-gray-800"
/>
className="flex items-start gap-3 border-b border-gray-200 py-5 dark:border-gray-800"
>
<Skeleton className="size-10 shrink-0 rounded-lg" />
<div className="min-w-0 flex-1">
<Skeleton className="h-6 w-48 max-w-full" />
<Skeleton className="mt-1 h-5 w-full" />
<Skeleton className="mt-1 h-5 w-4/5" />
<Skeleton className="mt-2 h-4 w-32" />
</div>
</div>
) : (
<ShowcaseCardSkeleton key={i} />
),
)}
</div>
</SkeletonGroup>
) : data?.showcases && data.showcases.length > 0 ? (
<>
<div className={gridClassName}>
Expand Down
8 changes: 6 additions & 2 deletions src/components/ShowcaseSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getMyShowcaseVotesQueryOptions,
} from '~/queries/showcases'
import { voteShowcase } from '~/utils/showcase.functions'
import { SkeletonGroup } from '~/components/ds/ui'
import { ShowcaseCard, ShowcaseCardSkeleton } from './ShowcaseCard'
import { Button } from '~/ui'
import { ArrowRightIcon, PlusIcon } from '@phosphor-icons/react'
Expand Down Expand Up @@ -205,11 +206,14 @@ export function ShowcaseSection({
</div>

{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<SkeletonGroup
label="Loading showcases"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"
>
{Array.from({ length: Math.max(limit, minItems) }).map((_, i) => (
<ShowcaseCardSkeleton key={i} />
))}
</div>
</SkeletonGroup>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{showcases.map(({ showcase, user }) => (
Expand Down
10 changes: 10 additions & 0 deletions src/components/ds/ds-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ export const dsNav: Array<DsNavSection> = [
to: '/ds/partner-rail',
sections: ['Rail', 'Tiers', 'Per-logo scale'],
},
{
label: 'Skeleton',
to: '/ds/skeleton',
sections: [
'Skeleton',
'On any surface',
'Grouping and accessibility',
'Match the real layout',
],
},
{
label: 'Spinner',
to: '/ds/spinner',
Expand Down
54 changes: 54 additions & 0 deletions src/components/ds/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,60 @@ export function Spinner({ className }: { className?: string }) {
)
}

/* --------------------------------------------------------------- Skeleton -- */

/**
* Placeholder block shown while content loads.
*
* The tint is an alpha overlay rather than an opaque surface token, so it always
* reads lighter than whatever sits behind it in dark mode and darker in light.
* An opaque `background-subtle` cannot do that: in dark mode it is lighter than
* the page but darker than an elevated card, so it inverts as it moves between
* surfaces.
*
* The pulse is wrapped in `motion-safe:` so it holds still under
* `prefers-reduced-motion`.
*/
export function Skeleton({
className,
...rest
}: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
aria-hidden="true"
className={twMerge(
'motion-safe:animate-pulse rounded bg-text-primary/10',
className,
)}
{...rest}
/>
)
}

/**
* Wrapper that announces a loading region to assistive tech. Wrap a group of
* `Skeleton` blocks in this so screen readers hear one message instead of
* nothing — the blocks themselves are `aria-hidden`.
*/
export function SkeletonGroup({
label = 'Loading',
className,
children,
...rest
}: React.ComponentPropsWithoutRef<'div'> & { label?: string }) {
return (
<div
role="status"
aria-busy="true"
aria-label={label}
className={className}
{...rest}
>
{children}
</div>
)
}

/* ----------------------------------------------------------------- Avatar -- */

type AvatarSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
Expand Down
Loading
Loading