// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Thumbnail.tsx * @input Uses React, stylex, Button, Skeleton, Spinner * @output Exports Thumbnail component, ThumbnailProps * @position Core implementation; consumed by index.ts * * Square preview card for image attachments. Shows a skeleton shimmer while * the image loads, the image on success, or a placeholder on failure. * The overlaid remove button sits on a fixed --color-overlay scrim with an * --color-on-dark icon so it always has sufficient contrast against the image. * * Images without `alt` are explicitly decorative (alt="" + * role="presentation" + aria-hidden, matching Avatar). A dev-time warning * fires once when `src` is set with no `alt` and no other name source. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Thumbnail/Thumbnail.doc.mjs * - /packages/core/src/Thumbnail/Thumbnail.test.tsx * - /packages/core/src/Thumbnail/index.ts * - /apps/storybook/stories/Thumbnail.stories.tsx * - /packages/cli/assets/templates/blocks/components/Thumbnail/ (showcase blocks) */ import { useState } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars, spacingVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { Button } from "../Button/index.js"; import { Icon } from "../Icon/index.js"; import { Skeleton } from "../Skeleton/index.js"; import { Spinner } from "../Spinner/index.js"; import { Tooltip } from "../Tooltip/Tooltip.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { useContainerReveal } from "../hooks/useContainerReveal.js"; import type { BaseProps } from "../BaseProps.js"; import { mergeProps } from "../utils/index.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { useTranslator } from "../i18n/index.js"; export interface ThumbnailProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Image source for the thumbnail preview. * Shows a skeleton while loading, the image on success, or a placeholder on error. */ src?: string; /** * Alt text for the image. * * When omitted, the image is explicitly decorative (`alt=""` + * `role="presentation"` + `aria-hidden`, matching Avatar) and hidden from * assistive technology. Provide `alt` whenever the image conveys content; * without it, screen reader users only hear the `label` (file name), or a * generic "thumbnail" if `label` is also missing — which triggers a * dev-time warning. */ alt?: string; /** * Accessible label for the thumbnail (e.g. file name). * Not rendered visually — shown in a tooltip on hover, exposed as the * accessible name of the thumbnail group, and used as the accessible * name for the remove button. */ label?: string; /** * Callback when the remove button is clicked. * When provided, an overlaid close button appears in the top-right corner. */ onRemove?: (e: React.MouseEvent) => void; /** * Click handler for opening a lightbox or detail view. * When provided, the thumbnail renders as interactive. */ onClick?: (e: React.MouseEvent) => void; /** * Content rendered below the thumbnail image area. * Use for metadata like file size, duration, or status. */ /** * Whether the thumbnail is in a loading state. * Shows a skeleton shimmer regardless of `src`. Use while uploading * or processing before an image URL is available. * @default false */ isLoading?: boolean; /** * Whether the thumbnail is in a disabled state. * @default false */ isDisabled?: boolean; /** * When the remove button is visible. * - `'hover'` — the button is revealed on hover, and on keyboard focus so * it stays reachable. On any touch-capable device it stays visible. * This is the default. * - `'always'` — the button is always shown. * * Only has an effect when `onRemove` is set. * @default 'hover' */ showRemoveOn?: 'always' | 'hover'; /** * Test ID for testing frameworks. */ 'data-testid'?: string; } // ============================================================================= // Styles // ============================================================================= const styles = { root: { kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kXwgrk: "xdt5ytf", kzqmXN: "x1fu8urw", kmuXW: "x2lah0s", kHBbk8: "xc8icb0", $$css: true as const }, imageContainer: { kVAEAm: "x1n2onr6", kzqmXN: "xh8yej3", kOBAk4: "x1plog1", kaIpWk: "xh6dtrn", kVQacm: "xb3r6kr", kWkggS: "x17x4s8c", $$css: true as const }, interactive: { kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, overlay: { k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kv0HGH: "xarstr8", kUfP38: "x1k48kgn", kloYau: "x2q1x1w", kRicXK: "x1ywzrc5", kPNhGg: "x97pup0", kA8PQs: "x1dlmc9c", ks3ayO: "xyhc2n1", kAcZsS: "x1k7wiig", $$css: true as const }, hoverOnPointer: { k8t4tK: "x1912f9e", $$css: true as const }, removeButtonOverrides: { "--_button-radius": "xh8ntw8", kVAEAm: "x1n2onr6", kZKoxP: "x1qx5ct2", k7Eaqz: "xt4ypqs", kWkggS: "x1u4601x", kMwMTN: "x9e3rv5", "--_thumbnail-hit-inset": "x1ur4yav x1h9khjt", k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kv0HGH: "xeu5z55", $$css: true as const }, disabled: { kSiTet: "xbyyjgo", kfzvcC: "x47corl", $$css: true as const } }; // ============================================================================= // Placeholder icon — a simple image silhouette // ============================================================================= function ImagePlaceholder() { return ; } // ============================================================================= // Component // ============================================================================= /** * A square thumbnail preview for image attachments. * * Shows a skeleton shimmer while the image loads, the image on success, or * a placeholder icon on failure / when no src is provided. An overlaid * remove button appears when `onRemove` is set — revealed on hover or * keyboard focus by default (`showRemoveOn`), or always shown. * * The remove button uses a fixed `--color-overlay` scrim with an * `--color-on-dark` icon so it stays legible on any image without sampling * the image's luminance. * * @example * ``` * {}} /> * {}} label="preview.png" /> * {}} showRemoveOn="always" /> * ``` */ export function Thumbnail({ src, alt, label, onRemove, onClick, isLoading = false, isDisabled = false, showRemoveOn = 'hover', xstyle, className, style, 'data-testid': testId, ref, ...props }: ThumbnailProps) { const t = useTranslator(); // Track the exact src that failed (rather than a boolean) so a changed src // gets a fresh load attempt instead of the stale error. const [erroredSrc, setErroredSrc] = useState(undefined); const hasSrc = src != null; const hasError = hasSrc && erroredSrc === src; const showSkeleton = isLoading && !hasSrc; const showImage = hasSrc && !showSkeleton && !hasError; const showUploadOverlay = isLoading && hasSrc; const showPlaceholder = !isLoading && !hasSrc || hasError; const isInteractive = onClick != null && !isDisabled && !isLoading; const hasRemove = onRemove != null && !isDisabled; const isHoverReveal = hasRemove && showRemoveOn === 'hover'; const { getContainerProps, getContentRevealProps } = useContainerReveal({ isEnabled: isHoverReveal }); const accessibleName = label && alt ? `${label} — ${alt}` : label ?? alt ?? t('@astryx.thumbnail.fallbackName'); // Without `alt`, the image is explicitly decorative rather than silently // empty-alt, matching Avatar's handling of unnamed images. const isImageDecorative = !alt; // Dev-time guardrail: `src` with no `alt` hides the image from assistive // technology. That is fine when the thumbnail is otherwise named — `label` // (or a consumer-provided aria name) becomes the group's accessible name — // but with no name source at all the group falls back to a generic // "thumbnail". const hasNameSource = alt != null || label != null || props['aria-label'] != null || props['aria-labelledby'] != null; useDevWarning('Thumbnail', '`src` is set without `alt` or `label`. The image is ' + 'treated as decorative and hidden from assistive technology, and ' + 'the thumbnail falls back to a generic "thumbnail" name. Pass ' + '`alt` to describe the image content, or `label` (file name) to ' + 'name the thumbnail.', hasSrc && !hasNameSource); const imageContent = <> {showImage && {alt setErroredSrc(src)} className="xh8yej3 x5yr21d xl1xv1r x1lliihq" />} {showSkeleton && } {showPlaceholder &&
} ; const removeButtonEl = hasRemove ?
: null; const thumbnail =
{isInteractive ? : imageContent} {showImage &&
} {showUploadOverlay &&
} {removeButtonEl}
; if (label != null) { return {thumbnail}; } return thumbnail; } Thumbnail.displayName = 'Thumbnail';