diff --git a/.changeset/mosaic-avatar-drag-to-upload.md b/.changeset/mosaic-avatar-drag-to-upload.md new file mode 100644 index 00000000000..d4c7c880ce9 --- /dev/null +++ b/.changeset/mosaic-avatar-drag-to-upload.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Drop an image onto the profile picture row in `UserProfile` or the logo row in `OrganizationProfile` to upload it. While a file is dragged over the row, the avatar darkens and shows an upload icon. `FileUpload.Dropzone` now ignores drags that carry no files, such as selected text or links. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ab8f11afefc..a8689147e98 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -57,6 +57,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | An organization permission checked with `checkAuthorization` | `ORGANIZATION_PERMISSIONS` (`src/utils/permissions.ts`) | | A verification code's length or resend cooldown | `CODE_LENGTH`, `CODE_RESEND_SECONDS` (`src/utils/verification.ts`) | | An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) | +| Dropping a file onto an avatar or logo row | `AvatarDropzone` (`src/components/avatar-dropzone/`) inside the row's `FileUpload.Root` | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | | An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) | | A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) | diff --git a/packages/mosaic/src/__tests__/feature/drag.ts b/packages/mosaic/src/__tests__/feature/drag.ts new file mode 100644 index 00000000000..56cc980f7ba --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/drag.ts @@ -0,0 +1,19 @@ +import { act } from '@testing-library/react'; + +export function dragFilesOver(target: Element, ...files: File[]) { + dispatchDrag(target, 'dragenter', files); +} + +export function dropFiles(target: Element, ...files: File[]) { + dispatchDrag(target, 'drop', files); +} + +function dispatchDrag(target: Element, type: 'dragenter' | 'drop', files: File[]) { + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + act(() => { + target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer })); + }); +} diff --git a/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.markers.stylex.ts b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.markers.stylex.ts new file mode 100644 index 00000000000..3c70d4aedab --- /dev/null +++ b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const avatarDropzoneScope = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.styles.ts b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.styles.ts new file mode 100644 index 00000000000..fc2ee6047fa --- /dev/null +++ b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.styles.ts @@ -0,0 +1,31 @@ +import * as stylex from '@stylexjs/stylex'; + +import { durationVars, easingVars } from '../../tokens.stylex'; +import { avatarDropzoneScope } from './avatar-dropzone.markers.stylex'; + +export const styles = stylex.create({ + overlay: { + inset: 0, + borderRadius: 'inherit', + alignItems: 'center', + backgroundColor: 'color-mix(in oklab, oklch(0 0 0) 50%, transparent)', + color: 'oklch(1 0 0)', + display: 'flex', + justifyContent: 'center', + opacity: { + default: 0, + [stylex.when.ancestor('[data-dragging]', avatarDropzoneScope)]: 1, + }, + pointerEvents: 'none', + position: 'absolute', + transitionDuration: { + default: durationVars['--cl-duration-fast'], + [stylex.when.ancestor('[data-dragging]', avatarDropzoneScope)]: durationVars['--cl-duration-base'], + }, + transitionProperty: 'opacity', + transitionTimingFunction: { + default: easingVars['--cl-ease-exit'], + [stylex.when.ancestor('[data-dragging]', avatarDropzoneScope)]: easingVars['--cl-ease-enter'], + }, + }, +}); diff --git a/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.tsx b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.tsx new file mode 100644 index 00000000000..8928429ccde --- /dev/null +++ b/packages/mosaic/src/components/avatar-dropzone/avatar-dropzone.tsx @@ -0,0 +1,50 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { ArrowUp } from '../../icons/glyphs/arrow-up'; +import { FileUpload } from '../../primitives/file-upload'; +import type { MosaicComponentProps, MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { Icon } from '../icon'; +import { avatarDropzoneScope } from './avatar-dropzone.markers.stylex'; +import { styles } from './avatar-dropzone.styles'; + +export type AvatarDropzoneProps = MosaicComponentProps<'div'>; + +const AvatarDropzoneRoot = React.forwardRef(function MosaicAvatarDropzoneRoot( + { xstyle, ...rest }, + ref, +) { + return ( + + ); +}); + +export type AvatarDropzoneOverlayProps = MosaicElementProps<'span'>; + +const AvatarDropzoneOverlay = React.forwardRef( + function MosaicAvatarDropzoneOverlay({ xstyle, ...rest }, ref) { + return ( + + + + ); + }, +); + +export const AvatarDropzone = { + Root: AvatarDropzoneRoot, + Overlay: AvatarDropzoneOverlay, +}; diff --git a/packages/mosaic/src/components/avatar-dropzone/index.ts b/packages/mosaic/src/components/avatar-dropzone/index.ts new file mode 100644 index 00000000000..d156004121b --- /dev/null +++ b/packages/mosaic/src/components/avatar-dropzone/index.ts @@ -0,0 +1,2 @@ +export { AvatarDropzone } from './avatar-dropzone'; +export type { AvatarDropzoneOverlayProps, AvatarDropzoneProps } from './avatar-dropzone'; diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx index 8d6e0349680..6abd47e1cf4 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { openDialog } from '../../../__tests__/feature/dialog'; +import { dragFilesOver, dropFiles } from '../../../__tests__/feature/drag'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, @@ -353,6 +354,33 @@ describe('OrganizationProfileGeneralPanel', () => { expect(await screen.findByRole('button', { name: 'Upload' })).toBeVisible(); }); + it('marks the row while a file is dragged over it and uploads the file dropped on it', async () => { + const { fapi } = await renderPanel(); + const row = await screen.findByText('Logo'); + const file = new File(['image'], 'logo.png', { type: 'image/png' }); + + dragFilesOver(row, file); + expect(row.closest('[data-dragging]')).not.toBeNull(); + + dropFiles(row, file); + expect(row.closest('[data-dragging]')).toBeNull(); + await waitFor(() => expect(fapi.memberships[0]?.organization.has_image).toBe(true)); + }); + + it('ignores a file dragged over the logo without manage permission', async () => { + await renderPanel(signedIn({ permissions: [] })); + const upload = holdRequests('post', '/v1/organizations/:organizationId/logo'); + const row = await screen.findByText('Logo'); + const file = new File(['image'], 'logo.png', { type: 'image/png' }); + + dragFilesOver(row, file); + expect(row.closest('[data-dragging]')).toBeNull(); + + dropFiles(row, file); + expect(upload.requests).toHaveLength(0); + upload.release(); + }); + it('localizes an oversized image rejection without sending a request', async () => { serveFapi(signedIn()); const { container } = await renderWithClerk( diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx index 14e14a4c582..22af779476b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { ActionMenuAction } from '../../../components/action-menu'; import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; +import { AvatarDropzone } from '../../../components/avatar-dropzone'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { Pen } from '../../../icons/glyphs/pen'; @@ -52,7 +53,7 @@ export function OrganizationProfileLogoRowView({ return ( } @@ -69,7 +70,7 @@ export function OrganizationProfileLogoRowView({ } }} > - + }> {initials} + @@ -91,7 +93,7 @@ export function OrganizationProfileLogoRowView({ hasImage={hasImage} onRemove={handleRemove} /> - + {error} ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx index 4e168c77d1c..20359af94fd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; +import { dragFilesOver, dropFiles } from '../../../../__tests__/feature/drag'; import { demandReverificationOnce, type FakeFapiSeed, @@ -149,6 +150,33 @@ describe('the user profile profile section', () => { expect(screen.getByRole('menuitem', { name: 'Remove avatar' })).toHaveAttribute('data-color', 'negative'); }); + it('marks the row while a file is dragged over it and uploads the file dropped on it', async () => { + await renderSection(); + const row = screen.getByText('Profile picture'); + const file = new File(['x'], 'me.png', { type: 'image/png' }); + + dragFilesOver(row, file); + expect(row.closest('[data-dragging]')).not.toBeNull(); + + dropFiles(row, file); + expect(row.closest('[data-dragging]')).toBeNull(); + expect(await screen.findByRole('button', { name: 'Manage profile picture' })).toBeInTheDocument(); + }); + + it('turns away a dropped file of the wrong type without uploading it', async () => { + await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + const row = screen.getByText('Profile picture'); + const file = new File(['x'], 'notes.pdf', { type: 'application/pdf' }); + + dragFilesOver(row, file); + dropFiles(row, file); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(upload.requests).toHaveLength(0); + upload.release(); + }); + it('says why an upload was refused', async () => { const { actor, container } = await renderSection(); failsWith('/v1/me/profile_image', { code: 'avatar_file_size_exceeded', message: 'Too large' }, 413); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx index 9bcaa255b84..ddd64d803d3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx @@ -1,6 +1,7 @@ import type { ActionMenuAction } from '../../../components/action-menu'; import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; +import { AvatarDropzone } from '../../../components/avatar-dropzone'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; @@ -36,7 +37,7 @@ export function UserProfilePictureRowView({ return ( } onReject={onReject} @@ -47,7 +48,7 @@ export function UserProfilePictureRowView({ } }} > - + }> {initials} + @@ -66,7 +68,7 @@ export function UserProfilePictureRowView({ hasImage={hasImage} onRemove={onRemove} /> - + {error} ); diff --git a/packages/mosaic/src/primitives/file-upload/README.md b/packages/mosaic/src/primitives/file-upload/README.md index 3a8bdae5783..3d97e79593b 100644 --- a/packages/mosaic/src/primitives/file-upload/README.md +++ b/packages/mosaic/src/primitives/file-upload/README.md @@ -107,7 +107,7 @@ added. | ------------------------ | --------------- | ------------------------------------------------------------------- | | `FileUpload.Root` | `
` | Owns the file state + hidden input, provides context | | `FileUpload.Trigger` | `