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:
@@ -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<UpdateAvailableModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const node = (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
@@ -309,4 +310,11 @@ export const UpdateAvailableModal: React.FC<UpdateAvailableModalProps> = ({
|
||||
</Card>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user