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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user