/********************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import { forwardRef, FunctionComponent, memo } from 'react';
import { Link as RouteLink } from 'react-router';
import { Paper, Typography, Box, Fade, Skeleton } from '@mui/material';
import { CSSObject, styled, Theme } from '@mui/material/styles';
import SaveAltIcon from '@mui/icons-material/SaveAlt';
import VerifiedIcon from '@mui/icons-material/Verified';
import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes';
import { SearchEntry } from '../extension-registry-types';
import { ExtensionIcon } from './extension/extension-icon';
import { ExtensionRatingStars } from '../pages/extension-detail/extension-rating-stars';
import { createRoute, formatCompactNumber } from '../utils';
import { MONO_FONT } from '../default/theme';
import { GridItemProps } from '../hooks/use-grid-cursor';
import { cardHoverLift, cardSurface, focusRing } from './page-primitives';
// Shared surface + footprint so the card and its skeleton occupy identical space.
// A size container so tight cards can shed the footer's review count.
const cardLayout = (theme: Theme): CSSObject => ({
...cardSurface(theme),
containerType: 'inline-size',
padding: '1rem 0.875rem',
[theme.breakpoints.down('sm')]: { padding: '0.75rem 0.625rem' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: '100%',
minHeight: '13rem'
});
const CardRoot = styled(Paper)(({ theme }) => ({
...cardLayout(theme),
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s',
...cardHoverLift(theme),
// Keyboard focus ring mirrors the search field's :focus-within style.
// Ring when the card link is keyboard-focused, or when it is the grid
// cursor and the cursor is visible (see useGridCursor).
'a:focus-visible &, [data-cursor-visible] a[data-active] &': focusRing(theme)
}));
const SkeletonRoot = styled(Paper)(({ theme }) => cardLayout(theme));
// Only the unknown parts are skeletons; the stars' empty state looks the same loaded or not.
const SkeletonContent: FunctionComponent = () => (
<>
>
);
/** Loading placeholder matching {@link ExtensionCard}'s footprint. */
export const ExtensionCardSkeleton: FunctionComponent = () => (
);
// The grid cursor props are optional: cards also render outside a cursor grid
// (curated sections, namespace detail).
export interface ExtensionCardProps extends Partial> {
/**
* The extension, or `undefined` for a loading skeleton. Keep a stable key
* across the swap so the fade plays once instead of restarting.
*/
extension?: SearchEntry;
/** Delay before the card fades in, so grids can stagger their cards. */
fadeDelayMs?: number;
/** When false, the card shows immediately without its entrance fade (e.g. restored from cache on back-nav). */
appear?: boolean;
}
export const ExtensionCard = memo(
forwardRef(function ExtensionCard(
{ extension, fadeDelayMs = 0, appear = true, ...linkProps },
ref
) {
const title = extension?.displayName ?? extension?.name;
const downloadCount = extension ? formatCompactNumber(extension.downloadCount ?? 0) : undefined;
const reviewCount = extension?.reviewCount ?? 0;
// One Fade over both states so it runs once and carries through the skeleton → card swap.
return (
{extension ? (
{title}
{/* Fixed two-line box so the meta rows align across cards with short descriptions. */}
{extension.description}
:first-of-type:hover': { flexShrink: 0, maxWidth: 'none' },
'& > :first-of-type:hover + *': { minWidth: 0, flexShrink: 1 },
'& > :last-of-type:hover': { flexShrink: 0, maxWidth: 'none' },
'&:has(> :last-of-type:hover) > :first-of-type': {
flexShrink: 1,
minWidth: 0
}
}}>
{extension.namespace}
{extension.verified && (
)}
{extension.version}
{extension.deprecated && (
deprecated
)}
{reviewCount > 0 && (
({formatCompactNumber(reviewCount)})
)}
{downloadCount !== '0' && (
{downloadCount}
)}
) : (
)}
);
})
);