feat: implement Portal component and refactor modals to use it for improved positioning

This commit is contained in:
hamed
2026-07-04 22:05:27 +03:30
parent ae1e40aeee
commit f3ca6d844f
9 changed files with 168 additions and 9 deletions
@@ -4,6 +4,7 @@ import {
MagnifyingGlassIcon, XMarkIcon, CheckIcon,
} from '@heroicons/react/24/outline';
import { useUiStore, HUES, type BrandHue } from '../../stores/uiStore';
import Portal from '../ui/Portal';
export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () => void }) {
const toggleSidebar = useUiStore((s) => s.toggleSidebar);
@@ -71,6 +72,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
{/* Settings Panel */}
{settingsPanelOpen && (
<Portal>
<div
className="overlay"
style={{ placeItems: 'stretch', justifyContent: 'flex-start' }}
@@ -176,6 +178,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
</div>
</div>
</div>
</Portal>
)}
</>
);
@@ -1,5 +1,6 @@
import React from 'react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Portal from './Portal';
interface Props {
open: boolean;
@@ -27,6 +28,7 @@ export default function ConfirmDialog({
if (!open) return null;
return (
<Portal>
<div className="overlay" onClick={onCancel}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
@@ -61,5 +63,6 @@ export default function ConfirmDialog({
</div>
</div>
</div>
</Portal>
);
}
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import MobileInput from './MobileInput';
import Portal from './Portal';
import { iranMobileSchema } from '../../lib/utils';
const inviteSchema = z.object({
@@ -39,6 +40,7 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
});
return (
<Portal>
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
@@ -71,5 +73,6 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
</form>
</div>
</div>
</Portal>
);
}
+9
View File
@@ -0,0 +1,9 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export default function Portal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
}
+8 -9
View File
@@ -350,7 +350,7 @@ function EditModal({ clinic, onClose, onSaved }: {
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
return (
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
@@ -455,7 +455,8 @@ function EditModal({ clinic, onClose, onSaved }: {
</div>
</form>
</div>
</div>
</div>,
document.body,
);
}
@@ -1301,24 +1302,22 @@ export default function ClinicDetailPage() {
onCancel={() => setDetachDoctorConfirm(null)}
/>
{/* Edit modal — portal to escape Leaflet transform context */}
{editOpen && createPortal(
{/* Edit modal — self-portals to escape transform context */}
{editOpen && (
<EditModal
clinic={clinic}
onClose={() => setEditOpen(false)}
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
/>,
document.body,
/>
)}
{/* Invite doctor modal */}
{inviteOpen && uuid && createPortal(
{inviteOpen && uuid && (
<InviteDoctorModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
/>,
document.body,
/>
)}
{/* Delete confirm */}
+3
View File
@@ -17,6 +17,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Clinic } from '../types';
import { useAuthStore } from '../stores/authStore';
import MobileInput from '../components/ui/MobileInput';
import Portal from '../components/ui/Portal';
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
@@ -244,6 +245,7 @@ export default function ClinicsPage() {
{/* Add Modal */}
{addOpen && (
<Portal>
<div className="overlay" onClick={() => setAddOpen(false)}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
@@ -287,6 +289,7 @@ export default function ClinicsPage() {
</form>
</div>
</div>
</Portal>
)}
<ConfirmDialog
@@ -4,6 +4,7 @@ import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse } from '../lib/api';
import Portal from '../components/ui/Portal';
import { formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
@@ -204,6 +205,7 @@ export default function PreRegistrationsPage() {
{/* Reject Dialog */}
{rejectTarget && (
<Portal>
<div className="overlay" onClick={() => setRejectTarget(null)}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
@@ -232,6 +234,7 @@ export default function PreRegistrationsPage() {
</div>
</div>
</div>
</Portal>
)}
</div>
);
+3
View File
@@ -8,6 +8,7 @@ import {
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import Portal from '../components/ui/Portal';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
@@ -101,6 +102,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
}) {
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
return (
<Portal>
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
@@ -140,6 +142,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
</div>
</div>
</div>
</Portal>
);
}