feat: implement PageLoader component and register maritime loader as the default theme loader

This commit is contained in:
estifanos
2026-08-14 11:35:34 +00:00
parent 652503d1de
commit f31e75c3de
16 changed files with 219 additions and 36 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useDispatch } from 'react-redux';
import { PageLoader } from '@ema-platform/ui';
import { authStorage } from '../utils/auth-storage';
import { hydrateAuth, logout, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -70,7 +71,7 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
// Rendering the router before the session resolves would let the guards
// redirect based on a state that is about to change.
if (!ready) return null;
if (!ready) return <PageLoader />;
return <>{children}</>;
}

View File

@@ -7,6 +7,9 @@ export * from "./lib/feedback/notify";
export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/feedback/PageLoader";
export * from "./lib/components/MaritimeLoader";
export * from "./lib/theme/maritime-loader-theme";
export * from "./lib/layout/AppHeader";
export * from "./lib/layout/AppSidebar";
export * from "./lib/layout/AppTopNav";

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, ComponentPropsWithoutRef } from "react";
import type { CSSProperties, ComponentPropsWithoutRef, Ref } from "react";
export interface MaritimeLoaderProps extends Omit<
ComponentPropsWithoutRef<"span">,
@@ -8,8 +8,9 @@ export interface MaritimeLoaderProps extends Omit<
size?: number | string;
/** Standalone CSS color. Mantine's Loader color is used automatically when omitted. */
color?: string;
/** Accessible status text. */
/** Accessible status text (aria-label only — renders no visible text). */
label?: string;
ref?: Ref<HTMLSpanElement>;
}
const styles = `
@@ -231,15 +232,6 @@ const styles = `
fill: currentColor;
}
}
.ema-loader__label {
display: block;
text-align: center;
font-size: 12px;
line-height: 1.2;
margin-top: 4px;
color: currentColor;
}
`;
function toCssSize(value: number | string | undefined) {
@@ -252,6 +244,7 @@ export function MaritimeLoader({
label = "Loading maritime services",
className,
style,
ref,
...props
}: MaritimeLoaderProps) {
const cssVariables = {
@@ -263,12 +256,15 @@ export function MaritimeLoader({
return (
<span
{...props}
ref={ref}
className={["ema-loader", className].filter(Boolean).join(" ")}
style={cssVariables}
role="status"
aria-label={label}
>
<style>{styles}</style>
<style href="ema-maritime-loader" precedence="low">
{styles}
</style>
<svg
className="ema-loader__svg"
@@ -419,18 +415,8 @@ export function MaritimeLoader({
<path d="M200 108c14 1 24-1 35-5" />
</g>
</svg>
{label && <span className="ema-loader__label">{label}</span>}
</span>
);
}
export default MaritimeLoader;
// how to use this component.
// <Center style={{ width: "90vw", height: "90vh" }}>
// <MaritimeLoader
// size={120}
// color="#075985"
// label="Loading Maritime Services..."
// />
// </Center>

View File

@@ -0,0 +1,26 @@
import { Center, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface PageLoaderProps {
/** Visible + accessible label. Defaults to the shared i18n "loading" string. */
label?: string;
/** Container height. Defaults to a full-page fill. */
height?: number | string;
}
/** The full-page/section loading state every screen should use instead of a bare `<Loader />`. */
export function PageLoader({ label, height = '60vh' }: PageLoaderProps) {
const { t } = useTranslation();
const resolvedLabel = label ?? t('loading', 'Loading…');
return (
<Center h={height}>
<Stack align="center" gap="xs">
<Loader size={64} aria-label={resolvedLabel} />
<Text c="dimmed" size="sm">
{resolvedLabel}
</Text>
</Stack>
</Center>
);
}

View File

@@ -0,0 +1,31 @@
import { Button, createTheme, Loader, type MantineLoaderComponent } from '@mantine/core';
import { MaritimeLoader } from '../components/MaritimeLoader';
/**
* Registers the branded ship loader as the default Mantine `Loader` type, and
* opts `Button`'s inline loading state back out to the stock oval — a 1.88:1
* ship does not fit a button's loader slot.
*
* Merge into an app theme with `mergeThemeOverrides(appTheme, maritimeLoaderTheme)`
* rather than importing this in `libs/shared`: `libs/ui` already depends on
* `libs/shared`, so the reverse import would be circular.
*/
export const maritimeLoaderTheme = createTheme({
components: {
Loader: Loader.extend({
defaultProps: {
// Mantine types custom loaders as ForwardRefExoticComponent; a plain
// function component satisfies the runtime contract (it forwards a
// ref as a normal prop under React 19) but not the structural type.
loaders: {
...Loader.defaultLoaders,
maritime: MaritimeLoader as unknown as MantineLoaderComponent,
},
type: 'maritime',
},
}),
Button: Button.extend({
defaultProps: { loaderProps: { type: 'oval' } },
}),
},
});