diff --git a/packages/ui/src/elements/Avatar.tsx b/packages/ui/src/elements/Avatar.tsx index 2560197a913..dd56fa84acf 100644 --- a/packages/ui/src/elements/Avatar.tsx +++ b/packages/ui/src/elements/Avatar.tsx @@ -128,6 +128,7 @@ export const Avatar = (props: AvatarProps) => { }), sx, ]} + data-rounded={rounded} > {ImgOrFallback} diff --git a/packages/ui/src/elements/AvatarUploader.tsx b/packages/ui/src/elements/AvatarUploader.tsx index 8a7f99b7a0d..10d3f403670 100644 --- a/packages/ui/src/elements/AvatarUploader.tsx +++ b/packages/ui/src/elements/AvatarUploader.tsx @@ -39,18 +39,14 @@ const validSize = (f: File) => f.size <= MAX_SIZE_BYTES; export const AvatarUploader = (props: AvatarUploaderProps) => { const { t } = useLocalizations(); - const [showUpload, setShowUpload] = React.useState(false); const [objectUrl, setObjectUrl] = React.useState(); + const [isDraggingOver, setIsDraggingOver] = React.useState(false); const card = useCardState(); const inputRef = React.useRef(null); const openDialog = () => inputRef.current?.click(); const { onAvatarChange, onAvatarRemove, title, avatarPreview, avatarPreviewPlaceholder, ...rest } = props; - const toggle = () => { - setShowUpload(!showUpload); - }; - const handleFileDrop = (file: File | null) => { if (file === null) { return setObjectUrl(''); @@ -60,7 +56,6 @@ export const AvatarUploader = (props: AvatarUploaderProps) => { card.setLoading(); return onAvatarChange(file) .then(() => { - toggle(); card.setIdle(); }) .catch(err => handleError(err, [], card.setError)); @@ -90,6 +85,44 @@ export const AvatarUploader = (props: AvatarUploaderProps) => { await handleFileDrop(f); }; + const isFileDrag = (e: React.DragEvent) => e.dataTransfer?.types?.includes('Files') ?? false; + + const handleDragEnter = (e: React.DragEvent) => { + if (card.isLoading || !isFileDrag(e)) { + return; + } + e.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + if (card.isLoading || !isFileDrag(e)) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (e: React.DragEvent) => { + // Only reset when leaving the container entirely, not when moving between children. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) { + return; + } + setIsDraggingOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + if (!isFileDrag(e)) { + return; + } + e.preventDefault(); + setIsDraggingOver(false); + if (card.isLoading) { + return; + } + void upload(e.dataTransfer.files?.[0]); + }; + const hasExistingImage = !!(avatarPreview.props as { imageUrl?: string })?.imageUrl; const previewElement = objectUrl ? React.cloneElement(avatarPreview, { imageUrl: objectUrl }) @@ -110,9 +143,29 @@ export const AvatarUploader = (props: AvatarUploaderProps) => { - {previewElement} + ({ + borderRadius: t.radii.$md, + transitionProperty: t.transitionProperty.$common, + transitionDuration: t.transitionDuration.$controls, + transitionTimingFunction: t.transitionTiming.$common, + ...(isDraggingOver && { + outline: `${t.borderWidths.$normal} dashed ${t.colors.$primary500}`, + outlineOffset: t.space.$0x5, + '&:has([data-rounded="true"])': { + borderRadius: t.radii.$circle, + }, + }), + })} + > + {previewElement} + { onClick={openDialog} /> - {!!onAvatarRemove && !showUpload && ( + {!!onAvatarRemove && (