import * as React from 'react' import * as Avatar from '@radix-ui/react-avatar' import { FieldWrapper } from '../shared/FieldWrapper' import type { SAILLabelPosition, SAILMarginSize, SAILAlign, SAILShape } from '../../types/sail' import type { DocumentImageProps } from './DocumentImage' import type { UserImageProps } from './UserImage' import type { WebImageProps } from './WebImage' import { alignMap, shapeMap } from '../../utils/sailMaps' import { resolveColorClass, resolveColorToHex, getContrastColor } from '../../utils/colorResolver' // Union type for all image types supported by ImageField type ImageFieldImage = DocumentImageProps | UserImageProps | WebImageProps // Type guard to check if an image is a UserImage function isUserImage(image: ImageFieldImage): image is UserImageProps { return 'imageType' in image && image.imageType === 'user' } // Type guard to check if an image is a WebImage function isWebImage(image: ImageFieldImage): image is WebImageProps { return 'source' in image } type ImageSize = | "ICON" | "ICON_PLUS" | "TINY" | "EXTRA_SMALL" | "SMALL" | "SMALL_PLUS" | "MEDIUM" | "MEDIUM_PLUS" | "LARGE" | "LARGE_PLUS" | "EXTRA_LARGE" | "FIT" | "GALLERY" type ImageStyle = "STANDARD" | "AVATAR" export interface ImageFieldProps { /** Text to display as the field label */ label?: string /** Determines where the label appears */ labelPosition?: SAILLabelPosition /** Supplemental text about this field */ instructions?: string /** Help tooltip text (max 500 characters) */ helpTooltip?: string /** Array of images to display (supports both document and user images) */ images: ImageFieldImage[] /** Controls visibility of the component */ showWhen?: boolean /** Determines how the images are sized */ size?: ImageSize /** Determines whether images can be viewed larger when clicked */ isThumbnail?: boolean /** Determines how the images are rendered */ style?: ImageStyle /** Determines the border radius of images. Ignored when style is "AVATAR" (always circular). */ shape?: SAILShape /** Determines alignment of the images */ align?: SAILAlign /** Additional text for screen readers */ accessibilityText?: string /** Margin above the component */ marginAbove?: SAILMarginSize /** Margin below the component */ marginBelow?: SAILMarginSize /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ className?: string } /** * Displays an array of images from document management or web * Maps to SAIL's a!imageField() function */ export const ImageField: React.FC = ({ label, labelPosition = "ABOVE", instructions, helpTooltip, images, showWhen = true, size = "MEDIUM", isThumbnail = false, style = "STANDARD", shape = "SEMI_ROUNDED", align = "START", accessibilityText, marginAbove = "NONE", marginBelow = "STANDARD", className }) => { // Visibility control if (!showWhen) return null const fieldId = `imagefield-${Math.random().toString(36).substr(2, 9)}` // Map SAIL size values to CSS dimensions for standard (rectangular) images const sizeMap: Record = { ICON: 'w-5 h-5', // 20x20px — square for icons ICON_PLUS: 'w-10 h-10', // 40x40px — square for icons TINY: 'w-16 h-auto', // ~64px wide EXTRA_SMALL: 'w-20 h-auto', // ~80px wide SMALL: 'w-24 h-auto', // ~96px wide SMALL_PLUS: 'w-36 h-auto', // ~144px wide MEDIUM: 'w-48 h-auto', // ~192px wide MEDIUM_PLUS: 'w-72 h-auto', // ~288px wide LARGE: 'w-96 h-auto', // ~384px wide LARGE_PLUS: 'w-[32rem] h-auto', // ~512px wide EXTRA_LARGE: 'w-[40rem] h-auto', // ~640px wide GALLERY: 'w-60 h-40', // 240x160px — fixed for gallery grid FIT: 'max-w-full h-auto' // Natural dimensions } // Map SAIL size values to CSS dimensions for avatar (square) images const avatarSizeMap: Record = { ICON: 'w-5 h-5', // 20x20px ICON_PLUS: 'w-10 h-10', // 40x40px TINY: 'w-16 h-16', // 64x64px EXTRA_SMALL: 'w-20 h-20', // 80x80px SMALL: 'w-24 h-24', // 96x96px SMALL_PLUS: 'w-36 h-36', // 144x144px MEDIUM: 'w-48 h-48', // 192x192px MEDIUM_PLUS: 'w-72 h-72', // 288x288px LARGE: 'w-96 h-96', // 384x384px LARGE_PLUS: 'w-96 h-96', // 384x384px (same as LARGE) EXTRA_LARGE: 'w-96 h-96', // 384x384px (same as LARGE) GALLERY: 'w-20 h-20', // 80x80px (square version of gallery) FIT: 'max-w-full h-auto aspect-square' // Natural dimensions, square } // Style-specific classes const getImageClasses = () => { // Use avatar (square) size map when style is AVATAR or shape is CIRCLE const activeSizeMap = (style === "AVATAR" || shape === "CIRCLE") ? avatarSizeMap : sizeMap const baseClasses = [ activeSizeMap[size], 'object-cover', // Maintain aspect ratio style === "AVATAR" ? 'rounded-full' : shapeMap[shape], isThumbnail ? 'cursor-pointer hover:opacity-80 transition-opacity' : '' ].filter(Boolean).join(' ') return baseClasses } // Container classes const containerClasses = [ 'flex flex-wrap gap-2', alignMap[align] ].filter(Boolean).join(' ') // Filter visible images const visibleImages = images.filter(img => img.showWhen !== false) // Render images const imagesElement = (
{visibleImages.map((imageProps, index) => { // Check if this is a user image if (isUserImage(imageProps)) { // Render user image with Radix Avatar const { user, altText, caption, link, backgroundColor } = imageProps const imageAlt = altText || user?.name || 'User profile photo' // Use avatar size map for sizing const activeSizeMap = avatarSizeMap const sizeClasses = activeSizeMap[size] // Resolve backgroundColor — supports SAIL tokens, palette colors, or hex let bgClass = 'bg-gray-200' let bgInlineStyle: React.CSSProperties | undefined let initialsColor: string | undefined if (backgroundColor) { const resolved = resolveColorClass(backgroundColor, 'bg') if (resolved) { bgClass = resolved } else { // Hex or unknown — use inline style bgClass = '' bgInlineStyle = { backgroundColor } } // Determine accessible text color for initials const hex = resolveColorToHex(backgroundColor) if (hex) { initialsColor = getContrastColor(hex) } } // Build fallback content (initials or default icon) const fallbackContent = user?.initials ? ( {user.initials} ) : ( // Default user icon SVG ) return ( {fallbackContent} ) } // Check if this is a web image if (isWebImage(imageProps)) { const imageClasses = getImageClasses() const imgElement = ( {imageProps.altText ) if (imageProps.link) { return (
imageProps.link!()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() imageProps.link!() } }} > {imgElement}
) } return (
{imgElement}
) } // Render document image (standard approach) const imageSrc = imageProps.document const imageClasses = getImageClasses() const imgElement = ( {imageProps.altText ) if (imageProps.link) { return (
imageProps.link!()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() imageProps.link!() } }} > {imgElement}
) } return (
{imgElement}
) })}
) return ( {imagesElement} ) }