/******************************************************************************** * 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} )} ) : ( )} ); }) );