import type { RefAttributes } from 'react'; import React, { Fragment, useCallback, useId, useMemo, useState } from 'react'; import classNames from 'classnames'; import { useTranslations } from '@shoptet/ui-core-web'; import type { Complete } from '@shoptet/utils'; import { destruct, type Exact, type SafeOmit, useStableCallback } from '@shoptet/utils'; import { Button } from '../../Button/Button'; import type { ImageProps } from '../../Image/Image'; import { Image } from '../../Image/Image'; import type { ModalProps } from '../../Modal/Modal'; import { Modal } from '../../Modal/Modal'; import { Paragraph } from '../../Typography/Paragraph/Paragraph'; import type { RadioGroupProps, SelectKey } from '../RadioGroup/RadioGroup'; import { RadioGroup } from '../RadioGroup/RadioGroup'; import type { RenderRadioGroupOptionProps } from '../RadioGroup/types'; import { dictionary, type ImageSelectTranslations } from './dictionary'; export type { SelectKey }; const DEFAULT_ASPECT_RATIO: ImageProps['aspectRatio'] = '3 / 2'; const DEFAULT_OPTION_SIZE: NonNullable = 'sm'; const DEFAULT_IMAGE_SIZE: NonNullable = 'md'; const DEFAULT_MIN_COLUMN_WIDTH: Record, number> = { sm: 130, md: 220, lg: 440, full: 960, }; export interface InheritedRadioGroupProps extends SafeOmit< RadioGroupProps, 'id' | 'ref' | 'key' | 'name' | 'onChange' | 'layout' | 'orientation' > {} export interface ImageSelectOwnProps< T extends object = object, V extends SelectKey = SelectKey, > extends RefAttributes { /** * The id of the trigger button. */ id?: string; /** * Called when the user confirms a selection in the modal. */ onChange?: (value: V | null, event: React.MouseEvent) => void; /** * Shows a loading spinner on the modal Confirm button. Use while a consumer-driven onChange is pending. */ loading?: boolean; /** * Aspect ratio applied to both the trigger preview and the modal tiles. * @default '3 / 2' */ aspectRatio?: ImageProps['aspectRatio']; /** * Returns the image source URL for an option. */ getOptionImageSrc(option: T): string; /** * Returns the alt text for an option's image. Falls back to `getOptionLabel(option)` when not provided. */ getOptionImageAlt?(option: T): string | undefined; /** * Override the trigger preview rendering. Receives the currently selected option. */ renderValue?(option: T | null): React.ReactNode; /** * Optional override for the trigger preview aspect ratio. Falls back to `aspectRatio`. */ previewAspectRatio?: ImageProps['aspectRatio']; /** * Size of the radio tiles inside the modal. * @default 'md' */ optionSize?: ImageProps['size']; /** * Modal title. Defaults to the trigger CTA ('Choose image' / 'Change image'). */ modalTitle?: string; /** * Optional description shown below the modal title. */ modalDescription?: React.ReactNode; modalMaxWidth?: ModalProps['maxWidth']; /** * Per-instance translation overrides. */ translations?: Partial; /** * Width of the image preview. * Affects only the layout of the trigger, not the images inside the modal. * @default 'md' */ width?: ImageProps['size']; } export interface ImageSelectProps extends ImageSelectOwnProps, InheritedRadioGroupProps {} export function ImageSelect(props: ImageSelectProps) { const [ownProps, radioGroupProps] = destruct(props, [ 'id', 'ref', 'key', 'onChange', 'loading', 'aspectRatio', 'getOptionImageSrc', 'getOptionImageAlt', 'renderValue', 'previewAspectRatio', 'optionSize', 'modalTitle', 'modalDescription', 'modalMaxWidth', 'translations', 'width', ]); ownProps satisfies Exact>, typeof ownProps>; radioGroupProps satisfies Exact>, typeof radioGroupProps>; const { id, key, ref, onChange, getOptionImageSrc, getOptionImageAlt, renderValue, loading, aspectRatio = DEFAULT_ASPECT_RATIO, previewAspectRatio, optionSize = DEFAULT_OPTION_SIZE, modalTitle, modalDescription, modalMaxWidth, translations: translationOverrides, width = DEFAULT_IMAGE_SIZE, ...exhaustedOwnProps } = ownProps; exhaustedOwnProps satisfies Record; const { value: valueProp, defaultValue, disabled, getOptionValue, getOptionLabel, options, renderOption, required, minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH[optionSize], ...radioGroupRestProps } = radioGroupProps; const baseTranslations = useTranslations(dictionary); const translations = useMemo( () => ({ ...baseTranslations, ...translationOverrides }), [baseTranslations, translationOverrides] ); const radioGroupName = useId(); const isControlled = valueProp !== undefined; const [localValue, setLocalValue] = useState(defaultValue ?? null); const selectedValue = isControlled ? valueProp : localValue; const selectedOption = useMemo( () => (selectedValue == null ? null : (options.find(option => getOptionValue(option) === selectedValue) ?? null)), [options, selectedValue, getOptionValue] ); const hasNoOptions = options.length === 0; const triggerDisabled = disabled || hasNoOptions; const triggerLabel = selectedOption ? translations.changeImage : translations.chooseImage; const resolvedModalTitle = modalTitle ?? triggerLabel; const [isOpen, setIsOpen] = useState(false); const [pendingValue, setPendingValue] = useState(selectedValue); const handleOpen = useStableCallback(() => { if (triggerDisabled) return; setPendingValue(selectedValue); setIsOpen(true); }); const handleCancel = useStableCallback(() => { setIsOpen(false); }); const handleConfirm: React.MouseEventHandler = useStableCallback(event => { if (!isControlled) { setLocalValue(pendingValue); } onChange?.(pendingValue, event); setIsOpen(false); }); const handlePendingChange = useCallback((next: V | null) => { setPendingValue(next); }, []); const resolveAlt = useCallback( (option: T) => getOptionImageAlt?.(option) ?? getOptionLabel(option), [getOptionImageAlt, getOptionLabel] ); const defaultRenderOption = useCallback( (option: T, renderProps: RenderRadioGroupOptionProps) => ( ), [aspectRatio, getOptionImageSrc, optionSize, resolveAlt] ); const previewRatio = previewAspectRatio ?? aspectRatio; const previewContent = renderValue ? ( renderValue(selectedOption) ) : ( {selectedOption ); const validity = radioGroupRestProps.validity; return (
{resolvedModalTitle}
{!!modalDescription && {modalDescription}} {...radioGroupRestProps} name={radioGroupName} value={pendingValue} onChange={handlePendingChange} disabled={disabled} options={options} getOptionValue={getOptionValue} getOptionLabel={getOptionLabel} renderOption={renderOption ?? defaultRenderOption} required={required} layout='grid' minColumnWidth={minColumnWidth} />
{({ primaryButtonProps, secondaryButtonProps }) => ( <> )}
); } ImageSelect.displayName = 'ImageSelect';