From ac50f0b48bf206be6eb1de3c0dabe3756fd5974a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 16:41:13 +0200 Subject: [PATCH] fix(admin): portal the update-available modal to document.body AdminSidebar's root div carries a Tailwind `transform` utility for the mobile slide-in, and per the CSS spec a transformed ancestor becomes the containing block for position:fixed descendants. The modal renders inline inside VersionInfo/AdminSidebar, so its `fixed inset-0` backdrop was trapped in the 256px sidebar column (measured 256 vs window 1440) -- copy buttons overlapping text, content truncating. Reuse the codebase's one existing portal convention, from gallery/FeedbackLimitReachedModal: assign the JSX to a const and return createPortal(node, document.body). Checked for other modals with the same trap; there are none. UpdateInstructionsDialog is also fixed inset-0 but is mounted from AdminDashboard inside
, and CustomerLayout has an identical transformed aside with no modal inside it. Refs testplan REPORT.md #13 (Part 3, B.07). --- .../components/admin/UpdateAvailableModal.tsx | 10 ++- .../updateAvailableModal.portal.test.tsx | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/__tests__/updateAvailableModal.portal.test.tsx diff --git a/frontend/src/components/admin/UpdateAvailableModal.tsx b/frontend/src/components/admin/UpdateAvailableModal.tsx index 00c846f2..617404b8 100644 --- a/frontend/src/components/admin/UpdateAvailableModal.tsx +++ b/frontend/src/components/admin/UpdateAvailableModal.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, ExternalLink, Copy, CheckCircle, ChevronDown, ChevronRight, ArrowUpCircle } from 'lucide-react'; @@ -123,7 +124,7 @@ export const UpdateAvailableModal: React.FC = ({ } }; - return ( + const node = (
= ({
); + + // Portal to body: the modal is rendered from VersionInfo inside the + // AdminSidebar, whose root carries a `transform` (mobile slide-in). A + // transformed ancestor becomes the containing block for `position: fixed` + // descendants, so without this the backdrop sized itself to the 256px + // sidebar column instead of the viewport (QA B.07). + return createPortal(node, document.body); }; diff --git a/frontend/src/components/admin/__tests__/updateAvailableModal.portal.test.tsx b/frontend/src/components/admin/__tests__/updateAvailableModal.portal.test.tsx new file mode 100644 index 00000000..02781872 --- /dev/null +++ b/frontend/src/components/admin/__tests__/updateAvailableModal.portal.test.tsx @@ -0,0 +1,62 @@ +/** + * UpdateAvailableModal is opened from VersionInfo, which lives inside + * AdminSidebar. The sidebar's root carries Tailwind's `transform` utility for + * its mobile slide-in, and a transformed ancestor becomes the containing block + * for `position: fixed` descendants — so the modal's `fixed inset-0` backdrop + * used to size itself to the 256px sidebar column instead of the viewport + * (QA B.07). The modal must portal to document.body to escape that. + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }), + }; +}); + +vi.mock('../../../config/api', () => ({ + api: { get: vi.fn().mockResolvedValue({ data: {} }) }, +})); + +import { UpdateAvailableModal } from '../UpdateAvailableModal'; + +const renderModal = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + // Stand-in for AdminSidebar's transformed root. +
+ + {}} + onDismiss={() => {}} + /> + +
+ ); +}; + +describe('UpdateAvailableModal portal (QA B.07)', () => { + it('renders the backdrop as a direct child of document.body', () => { + renderModal(); + + const backdrop = document.querySelector('.fixed.inset-0'); + expect(backdrop).not.toBeNull(); + expect(backdrop!.parentElement).toBe(document.body); + }); + + it('does not render the backdrop inside the transformed sidebar subtree', () => { + const { container } = renderModal(); + + const transformed = container.querySelector('.transform'); + expect(transformed).not.toBeNull(); + expect(transformed!.querySelector('.fixed.inset-0')).toBeNull(); + expect(container.querySelector('.fixed.inset-0')).toBeNull(); + }); +});