feat: implement ModalFooter component for consistent modal action layout

This commit is contained in:
estifanos
2026-08-05 08:50:38 +00:00
parent c8df8e1578
commit bbae230ad5
21 changed files with 152 additions and 74 deletions

View File

@@ -44,7 +44,7 @@ import {
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config';
@@ -529,7 +529,7 @@ export function CertificateDesignerPage() {
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
@@ -550,7 +550,7 @@ export function CertificateDesignerPage() {
>
{t('designer.create', 'Create')}
</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
</Container>

View File

@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
@@ -199,10 +199,10 @@ export function CertificationPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
</Group>
</ModalFooter>
</Modal>
</Stack>
);

View File

@@ -33,6 +33,7 @@ import {
useErrorHandler,
AdvancedTable,
useServerTable,
ModalFooter,
type AdvancedColumn,
} from "@ema-platform/ui";
import { LocationPage } from "../../location/pages/LocationPage";
@@ -388,14 +389,14 @@ function ProfessionTab() {
name: deleteTarget?.name?.[locale] ?? "",
})}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")}
</Button>
<Button color="red" onClick={confirmDeleteProf} size="sm">
{t("configuration.delete")}
</Button>
</Group>
</ModalFooter>
</Modal>
</>
);

View File

@@ -40,7 +40,7 @@ import {
IconX,
IconDetails
} from "@tabler/icons-react";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
import {
useGetExamQuery,
useUpdateExamMutation,
@@ -494,14 +494,14 @@ export function ExamDetailPage() {
mode="manual"
actions={setWhatAction}
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</ModalFooter>
</>
) : (
<>
@@ -531,14 +531,14 @@ export function ExamDetailPage() {
onChange={setDraftQuestions}
mode="random"
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</ModalFooter>
</>
)}
</Stack>

View File

@@ -31,7 +31,7 @@ import {
IconClipboardList,
IconDetails,
} from "@tabler/icons-react";
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from "@ema-platform/ui";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from "@ema-platform/ui";
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
import {
useGetExamsQuery,
@@ -600,14 +600,14 @@ export function ExamPage() {
name: deleteTarget?.title?.[locale] ?? "",
})}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("exam.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("exam.delete")}
</Button>
</Group>
</ModalFooter>
</Modal>
</Stack>
);

View File

@@ -3,7 +3,6 @@ import {
Alert,
Button,
Checkbox,
Group,
Modal,
Select,
Stack,
@@ -13,6 +12,7 @@ import {
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
/>
)}
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
>
{t(action.labelKey)}
</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
);

View File

@@ -57,7 +57,7 @@ import {
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
import { ErrorState } from '@ema-platform/ui';
import { ErrorState, ModalFooter } from '@ema-platform/ui';
import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { DecisionBar } from '../components/DecisionBar';
@@ -894,7 +894,7 @@ export function LicenseReviewPage() {
value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)}
/>
<Group justify="flex-end">
<ModalFooter>
<Tooltip
label={t('review.pickDate', 'Pick a date and time first')}
disabled={Boolean(inspectionDate)}
@@ -924,7 +924,7 @@ export function LicenseReviewPage() {
>
<IconCheck size={18} />
</ActionIcon>
</Group>
</ModalFooter>
</Stack>
</Modal>
@@ -942,7 +942,7 @@ export function LicenseReviewPage() {
autosize
minRows={3}
/>
<Group grow>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
@@ -989,7 +989,7 @@ export function LicenseReviewPage() {
>
<IconX size={18} />
</ActionIcon>
</Group>
</ModalFooter>
</Stack>
</Modal>
</Container>

View File

@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
@@ -226,14 +226,14 @@ export function LocationPage() {
name: selectedLocation?.names[locale] ?? '',
})}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDeleteModal} size="sm">
{t('location.cancel')}
</Button>
<Button color="red" onClick={handleDeleteConfirm} size="sm">
{t('location.delete')}
</Button>
</Group>
</ModalFooter>
</Modal>
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />

View File

@@ -26,7 +26,7 @@ import {
IconInfoCircle,
IconLock,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, ModalFooter } from '@ema-platform/ui';
import {
extractErrorMessage,
localized,
@@ -364,14 +364,14 @@ function FeeEditModal({
</>
)}
<Group justify="flex-end" mt="xs">
<ModalFooter mt="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={save} loading={isLoading}>
Save fees
</Button>
</Group>
</ModalFooter>
</Stack>
)}
</Modal>

View File

@@ -18,7 +18,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetQuestionsQuery,
@@ -255,10 +255,10 @@ export function QuestionPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.deleteConfirmText')}</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
</Group>
</ModalFooter>
</Modal>
</Stack>
);

View File

@@ -17,7 +17,7 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
import type { Exam } from '../../exam/types/exam';
@@ -195,12 +195,12 @@ export function RecordResultModal({
size="sm"
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')}
</Button>
</Group>
</ModalFooter>
</>
)}

View File

@@ -36,7 +36,7 @@ import {
IconChartBar,
IconSearch,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
@@ -462,7 +462,7 @@ export function ResultPage() {
</>
)}
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button
onClick={handleDetailSave}
@@ -472,7 +472,7 @@ export function ResultPage() {
>
{t('result.save')}
</Button>
</Group>
</ModalFooter>
</Stack>
) : (
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
@@ -481,10 +481,10 @@ export function ResultPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">{t('result.deleteConfirmText')}</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
</Group>
</ModalFooter>
</Modal>
{/* Choose exam, then record */}
@@ -501,10 +501,10 @@ export function ResultPage() {
searchable
required
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>

View File

@@ -30,7 +30,7 @@ import {
IconSettings,
IconTrash,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, ModalFooter } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
/>
<Divider />
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button color="teal" onClick={saveField}>
{isNew ? 'Add Field' : 'Save Changes'}
</Button>
</Group>
</ModalFooter>
</Stack>
</Drawer>
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
This cannot be undone.
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
</Stack>

View File

@@ -49,6 +49,7 @@ import {
type FieldErrors,
type ValidationIssue,
} from '@ema-platform/api';
import { ModalFooter } from '@ema-platform/ui';
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots';
import { StaffEvidence } from '../components/StaffEvidence';
@@ -617,17 +618,19 @@ export function LicenseApplicationPage() {
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
min={0}
/>
<Button
onClick={async () => {
if (!newStaff.fullName.trim() || !staffModal) return;
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
setStaffModal(null);
refetch();
}}
>
Add
</Button>
<ModalFooter>
<Button
onClick={async () => {
if (!newStaff.fullName.trim() || !staffModal) return;
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
setStaffModal(null);
refetch();
}}
>
Add
</Button>
</ModalFooter>
</Stack>
</Modal>
</Container>

View File

@@ -19,7 +19,7 @@ import {
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, ModalFooter } from '@ema-platform/ui';
/**
* The applicant's modes of operation — what they do, and therefore which
@@ -199,7 +199,7 @@ export function OperationsFormContent({
Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed.
</Text>
<Group justify="flex-end" gap="sm">
<ModalFooter gap="sm">
<Button
variant="default"
onClick={() => setConfirmingRemoval(false)}
@@ -209,7 +209,7 @@ export function OperationsFormContent({
<Button color="orange" loading={saving} onClick={persist}>
Remove and save
</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
</Stack>

View File

@@ -46,7 +46,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { CountrySelect, notify, useErrorHandler } from '@ema-platform/ui';
import { CountrySelect, notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -479,10 +479,10 @@ function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () =>
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
</SimpleGrid>
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
<Group justify="flex-end" mt="sm">
<ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
);
@@ -503,10 +503,10 @@ function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () =>
</SimpleGrid>
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
<Group justify="flex-end" mt="sm">
<ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
);
@@ -533,10 +533,10 @@ function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: ()
<AmharicDatePicker label="To" value={toDate} onChange={setToDate} />
</SimpleGrid>
<TextInput label="Engagement Port" placeholder="Port name" />
<Group justify="flex-end" mt="sm">
<ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
);
@@ -560,10 +560,10 @@ function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => voi
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
</SimpleGrid>
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
<Group justify="flex-end" mt="sm">
<ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>
);

View File

@@ -93,6 +93,22 @@ export const portalTheme = createTheme({
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',

View File

@@ -31,6 +31,24 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
components: {
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},

View File

@@ -1,5 +1,6 @@
export * from "./lib/input/BilingualInput";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
export * from "./lib/feedback/FeatureUnavailable";

View File

@@ -1,4 +1,5 @@
import { Modal, Button, Group, Text } from '@mantine/core';
import { Modal, Button, Text } from '@mantine/core';
import { ModalFooter } from './ModalFooter';
interface ConfirmModalProps {
opened: boolean;
@@ -26,14 +27,14 @@ export function ConfirmModal({
<Text size="sm" mb="xl">
{message}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="subtle" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</Group>
</ModalFooter>
</Modal>
);
}

View File

@@ -0,0 +1,38 @@
import { Group, type GroupProps } from '@mantine/core';
import type { CSSProperties } from 'react';
/**
* Action row pinned to the bottom of a modal.
*
* Modal bodies scroll (see the Modal styles in the theme), which would carry the
* confirm button off-screen on a tall modal. Sticking to the bottom of the
* scrollport keeps the decision reachable from any scroll position. Negative
* margins bleed the background over the body's padding so nothing shows through
* underneath; on a modal short enough not to scroll this renders identically to
* a plain Group.
*/
export function ModalFooter({
children,
style,
...props
}: Omit<GroupProps, 'style'> & { style?: CSSProperties }) {
return (
<Group
justify="flex-end"
{...props}
style={{
position: 'sticky',
bottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginInline: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginBottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
padding: 'var(--mb-padding, var(--mantine-spacing-md))',
background: 'var(--mantine-color-body)',
borderTop: '1px solid var(--mantine-color-default-border)',
zIndex: 1,
...style,
}}
>
{children}
</Group>
);
}