From 130f5226c0cca8efabe0edbdbfaf0d86904bc6d5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 14:01:09 +0300 Subject: [PATCH] refactor(profile): use shared ImageView lightbox for workspace photos Replace the bespoke inline lightbox in ProfileUserWorkspacePhotos with the shared LazyModal.ImageView (ImageModal), so both surfaces share one implementation and can't drift. The shared modal adds a FLIP zoom-from-thumbnail entrance, capture-phase Escape handling, body scroll-lock with scrollbar compensation, and an onError fallback. - Open the lightbox via useLazyModal().openModal with the clicked thumbnail's bounds (getImageOriginRect) so it expands from the photo. - Thread the click event through WorkspacePhotoItem's onClick to capture those bounds. - Drop the selectedPhoto state, the inline dialog markup, the Esc listener, and the now-unused CloseButton/useEventListener imports. - Update the spec to assert the click delegates to LazyModal.ImageView (the lightbox's own close/Esc behavior is covered by ImageModal.spec). Co-Authored-By: Claude Opus 4.8 --- .../ProfileUserWorkspacePhotos.spec.tsx | 52 ++++++++-------- .../ProfileUserWorkspacePhotos.tsx | 61 ++++++------------- .../workspacePhotos/WorkspacePhotoItem.tsx | 7 ++- 3 files changed, 49 insertions(+), 71 deletions(-) diff --git a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx index 13cf6632501..ed9d348b7dd 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx @@ -4,6 +4,7 @@ import type { PublicProfile } from '../../../../lib/user'; import { ProfileUserWorkspacePhotos } from './ProfileUserWorkspacePhotos'; import { useUserWorkspacePhotos } from '../../hooks/useUserWorkspacePhotos'; import { useGear } from '../../hooks/useGear'; +import { LazyModal } from '../../../../components/modals/common/types'; jest.mock('../../hooks/useUserWorkspacePhotos', () => ({ ...jest.requireActual('../../hooks/useUserWorkspacePhotos'), @@ -22,6 +23,11 @@ jest.mock('../../../../hooks/usePrompt', () => ({ usePrompt: () => ({ showPrompt: jest.fn() }), })); +const mockOpenModal = jest.fn(); +jest.mock('../../../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ openModal: mockOpenModal }), +})); + const mockUseUserWorkspacePhotos = useUserWorkspacePhotos as jest.MockedFunction; const mockUseGear = useGear as jest.MockedFunction; @@ -39,11 +45,6 @@ const baseUser: PublicProfile = { const photo = { id: 'p1', image: 'https://daily.dev/desk.png', position: 0 }; -const renderAndOpenLightbox = () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'View workspace photo' })); -}; - beforeEach(() => { jest.clearAllMocks(); mockUseUserWorkspacePhotos.mockReturnValue({ @@ -63,27 +64,28 @@ beforeEach(() => { }); describe('ProfileUserWorkspacePhotos lightbox', () => { - it('opens a dialog with a blurred backdrop when a photo is clicked', () => { - renderAndOpenLightbox(); - - expect( - screen.getByRole('dialog', { name: 'Workspace photo lightbox' }), - ).toBeInTheDocument(); - const backdrop = screen.getByRole('button', { name: 'Close lightbox' }); - expect(backdrop.className).toMatch(/backdrop-blur/); - }); - - it('closes the lightbox when the backdrop is clicked', () => { - renderAndOpenLightbox(); - - fireEvent.click(screen.getByRole('button', { name: 'Close lightbox' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); + it('opens the shared image lightbox with the photo when clicked', () => { + render(); - it('closes the lightbox when the close button is clicked', () => { - renderAndOpenLightbox(); + fireEvent.click( + screen.getByRole('button', { name: 'View workspace photo' }), + ); - fireEvent.click(screen.getByRole('button', { name: 'Close' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockOpenModal).toHaveBeenCalledTimes(1); + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + type: LazyModal.ImageView, + props: expect.objectContaining({ + src: photo.image, + alt: 'Workspace', + originRect: expect.objectContaining({ + top: expect.any(Number), + left: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + }), + }), + ); }); }); diff --git a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx index 22381f51518..cbe82924db8 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx @@ -15,7 +15,6 @@ import { sortableKeyboardCoordinates, rectSortingStrategy, } from '@dnd-kit/sortable'; -import { useEventListener } from '../../../../hooks/useEventListener'; import type { PublicProfile } from '../../../../lib/user'; import { useUserWorkspacePhotos, @@ -33,7 +32,9 @@ import { ButtonVariant, } from '../../../../components/buttons/Button'; import { CameraIcon, SettingsIcon } from '../../../../components/icons'; -import CloseButton from '../../../../components/CloseButton'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { getImageOriginRect } from '../../../../components/modals/ImageModal'; import { SortableWorkspacePhotoItem } from './WorkspacePhotoItem'; import { WorkspacePhotoUploadModal } from './WorkspacePhotoUploadModal'; import { GearModal } from '../gear/GearModal'; @@ -63,10 +64,10 @@ export function ProfileUserWorkspacePhotos({ const { displayToast } = useToastNotification(); const { showPrompt } = usePrompt(); const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false); const [isGearModalOpen, setIsGearModalOpen] = useState(false); - const [selectedPhoto, setSelectedPhoto] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -197,20 +198,19 @@ export function ProfileUserWorkspacePhotos({ setIsGearModalOpen(false); }, []); - const handlePhotoClick = useCallback((photo: { image: string }) => { - setSelectedPhoto(photo.image); - }, []); - - const handleCloseLightbox = useCallback(() => { - setSelectedPhoto(null); - }, []); - - // Close lightbox on ESC key - useEventListener(globalThis as unknown as Window, 'keydown', (event) => { - if (event.key === 'Escape' && selectedPhoto) { - handleCloseLightbox(); - } - }); + const handlePhotoClick = useCallback( + (photo: { image: string }, event: React.MouseEvent) => { + openModal({ + type: LazyModal.ImageView, + props: { + src: photo.image, + alt: 'Workspace', + originRect: getImageOriginRect(event.currentTarget), + }, + }); + }, + [openModal], + ); const hasPhotos = photos.length > 0; const hasGear = gearItems.length > 0; @@ -355,33 +355,6 @@ export function ProfileUserWorkspacePhotos({ onSubmit={handleAddGear} /> )} - - {selectedPhoto && ( -
-
- )} ); } diff --git a/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx b/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx index 472f70e60f2..b548674bf5d 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx @@ -15,7 +15,10 @@ interface WorkspacePhotoItemProps { photo: UserWorkspacePhoto; isOwner: boolean; onDelete?: (photo: UserWorkspacePhoto) => void; - onClick?: (photo: UserWorkspacePhoto) => void; + onClick?: ( + photo: UserWorkspacePhoto, + event: React.MouseEvent, + ) => void; } export function WorkspacePhotoItem({ @@ -35,7 +38,7 @@ export function WorkspacePhotoItem({