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 <main>, and CustomerLayout has an identical transformed
aside with no modal inside it.

Refs testplan REPORT.md #13 (Part 3, B.07).
This commit is contained in:
Paul Nothaft
2026-09-01 16:41:13 +02:00
parent 1be27404fa
commit ac50f0b48b
2 changed files with 71 additions and 1 deletions
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, ExternalLink, Copy, CheckCircle, ChevronDown, ChevronRight, ArrowUpCircle } from 'lucide-react'; import { X, ExternalLink, Copy, CheckCircle, ChevronDown, ChevronRight, ArrowUpCircle } from 'lucide-react';
@@ -123,7 +124,7 @@ export const UpdateAvailableModal: React.FC<UpdateAvailableModalProps> = ({
} }
}; };
return ( const node = (
<div <div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose} onClick={onClose}
@@ -309,4 +310,11 @@ export const UpdateAvailableModal: React.FC<UpdateAvailableModalProps> = ({
</Card> </Card>
</div> </div>
); );
// 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);
}; };
@@ -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<typeof import('react-i18next')>('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.
<div className="transform" style={{ transform: 'translateX(0)' }}>
<QueryClientProvider client={client}>
<UpdateAvailableModal
currentVersion="1.0.0"
latestVersion="1.1.0"
onClose={() => {}}
onDismiss={() => {}}
/>
</QueryClientProvider>
</div>
);
};
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();
});
});