From 18f18b71dcbd9ebda91e1468aa9daf3ce0a3ea5f Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:21:11 +0300 Subject: [PATCH] feat: add MaritimeLoader component for enhanced loading experience --- libs/ui/src/index.ts | 29 +- libs/ui/src/lib/components/MaritimeLoader.tsx | 436 ++++++++++++++++++ 2 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 libs/ui/src/lib/components/MaritimeLoader.tsx diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 37584299b..ae512fe36 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -1,14 +1,15 @@ -export * from './lib/input/BilingualInput'; -export * from './lib/input/CountrySelect'; -export * from './lib/feedback/ConfirmModal'; -export * from './lib/feedback/ApiErrorAlert'; -export * from './lib/feedback/notify'; -export * from './lib/feedback/use-error-handler'; -export * from './lib/layout/AppHeader'; -export * from './lib/layout/AppSidebar'; -export * from './lib/layout/BrandAvatar'; -export * from './lib/layout/ColorSchemeToggle'; -export * from './lib/layout/LanguageSwitcher'; -export * from './lib/layout/PageHeader'; -export * from './lib/data/AdvancedTable'; -export * from './lib/data/useServerTable'; +export * from "./lib/input/BilingualInput"; +export * from "./lib/input/CountrySelect"; +export * from "./lib/feedback/ConfirmModal"; +export * from "./lib/feedback/ApiErrorAlert"; +export * from "./lib/feedback/notify"; +export * from "./lib/feedback/use-error-handler"; +export * from "./lib/layout/AppHeader"; +export * from "./lib/layout/AppSidebar"; +export * from "./lib/layout/BrandAvatar"; +export * from "./lib/layout/ColorSchemeToggle"; +export * from "./lib/layout/LanguageSwitcher"; +export * from "./lib/layout/PageHeader"; +export * from "./lib/data/AdvancedTable"; +export * from "./lib/data/useServerTable"; +export * from "./lib/components/MaritimeLoader" diff --git a/libs/ui/src/lib/components/MaritimeLoader.tsx b/libs/ui/src/lib/components/MaritimeLoader.tsx new file mode 100644 index 000000000..a02ee2730 --- /dev/null +++ b/libs/ui/src/lib/components/MaritimeLoader.tsx @@ -0,0 +1,436 @@ +import type { CSSProperties, ComponentPropsWithoutRef } from "react"; + +export interface MaritimeLoaderProps extends Omit< + ComponentPropsWithoutRef<"span">, + "children" | "color" +> { + /** Standalone size. Mantine's Loader size is used automatically when omitted. */ + size?: number | string; + /** Standalone CSS color. Mantine's Loader color is used automatically when omitted. */ + color?: string; + /** Accessible status text. */ + label?: string; +} + +const styles = ` + .ema-loader { + position: relative; + display: inline-block; + width: calc(var(--ema-size, var(--loader-size, 80px)) * 1.88); + color: var(--ema-color, var(--loader-color, #075985)); + vertical-align: middle; + } + + .ema-loader__svg { + display: block; + width: 100%; + height: var(--ema-size, var(--loader-size, 80px)); + overflow: visible; + filter: drop-shadow( + 0 calc(var(--ema-size, var(--loader-size, 80px)) * 0.035) + calc(var(--ema-size, var(--loader-size, 80px)) * 0.04) + rgb(7 39 58 / 18%) + ); + } + + .ema-loader__ship { + transform-box: fill-box; + transform-origin: 50% 82%; + animation: ema-ship-float 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite; + } + + .ema-loader__shadow { + fill: currentColor; + opacity: 0.12; + transform-box: fill-box; + transform-origin: center; + animation: ema-shadow-breathe 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite; + } + + .ema-loader__deck { + fill: currentColor; + opacity: 0.82; + } + + .ema-loader__superstructure > path:first-child, + .ema-loader__bridge-top { + fill: #f8fbfc; + stroke: currentColor; + stroke-width: 2.4; + stroke-linejoin: round; + } + + .ema-loader__bridge-top { stroke-width: 2; } + + .ema-loader__window { + fill: #bfe9ff; + stroke: currentColor; + stroke-width: 0.7; + } + + .ema-loader__cabin-line { + fill: currentColor; + opacity: 0.35; + } + + .ema-loader__funnel > path:first-child { + fill: #eef3f5; + stroke: currentColor; + stroke-width: 2; + stroke-linejoin: round; + } + + .ema-loader__green { fill: #078930; } + .ema-loader__yellow { fill: #fcd116; } + .ema-loader__red { fill: #da121a; } + + .ema-loader__mast { + fill: currentColor; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + + .ema-loader__flag-pole { + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + } + + .ema-loader__flag { + transform-box: fill-box; + transform-origin: left center; + animation: ema-flag-wave 0.95s ease-in-out infinite alternate; + } + + .ema-loader__hull { + fill: #f8fbfc; + stroke: currentColor; + stroke-width: 1.4; + stroke-linejoin: round; + } + + .ema-loader__lower-hull { + fill: currentColor; + opacity: 0.92; + } + + .ema-loader__waterline { + fill: none; + stroke: rgb(255 255 255 / 52%); + stroke-width: 2.4; + stroke-linecap: round; + } + + .ema-loader__bow-highlight { + fill: none; + stroke: rgb(255 255 255 / 46%); + stroke-width: 2.3; + stroke-linecap: round; + stroke-linejoin: round; + } + + .ema-loader__portholes { + fill: #d7f2ff; + stroke: currentColor; + stroke-width: 0.8; + } + + .ema-loader__cargo path { + fill: none; + stroke: rgb(255 255 255 / 22%); + stroke-width: 1; + } + + .ema-loader__container-dark rect { fill: #3f4a52; } + .ema-loader__container-muted rect { fill: #7d8991; } + .ema-loader__container-steel rect { fill: #59656d; } + .ema-loader__container-light rect { fill: #aab2b8; } + .ema-loader__container-medium rect { fill: #6c7880; } + + .ema-loader__water-back, + .ema-loader__water-front, + .ema-loader__foam { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + + .ema-loader__water-back { + stroke: currentColor; + stroke-width: 5; + opacity: 0.28; + stroke-dasharray: 58 12; + animation: ema-water-back 2.8s linear infinite; + } + + .ema-loader__water-front { + stroke: currentColor; + stroke-width: 6; + opacity: 0.55; + stroke-dasharray: 70 10; + animation: ema-water-front 1.9s linear infinite; + } + + .ema-loader__foam { + stroke: rgb(255 255 255 / 78%); + stroke-width: 3; + stroke-dasharray: 11 7; + animation: ema-foam-drift 1.65s linear infinite; + } + + @keyframes ema-ship-float { + 0%, 100% { transform: translateY(1.5px) rotate(-0.65deg); } + 50% { transform: translateY(-3px) rotate(0.65deg); } + } + + @keyframes ema-shadow-breathe { + 0%, 100% { transform: scaleX(1.02); opacity: 0.14; } + 50% { transform: scaleX(0.9); opacity: 0.08; } + } + + @keyframes ema-flag-wave { + from { transform: skewY(-3deg) scaleX(0.94); } + to { transform: skewY(3deg) scaleX(1.04); } + } + + @keyframes ema-water-back { + to { stroke-dashoffset: -140; } + } + + @keyframes ema-water-front { + to { stroke-dashoffset: 160; } + } + + @keyframes ema-foam-drift { + to { stroke-dashoffset: -36; } + } + + @media (prefers-reduced-motion: reduce) { + .ema-loader__ship, + .ema-loader__shadow, + .ema-loader__flag, + .ema-loader__water-back, + .ema-loader__water-front, + .ema-loader__foam { + animation: none; + } + } + +@media (forced-colors: active) { + .ema-loader__green, + .ema-loader__yellow, + .ema-loader__red, + .ema-loader__container-dark rect, + .ema-loader__container-muted rect, + .ema-loader__container-steel rect, + .ema-loader__container-light rect, + .ema-loader__container-medium rect { + 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) { + return typeof value === "number" ? `${value}px` : value; +} + +export function MaritimeLoader({ + size, + color, + label = "Loading maritime services", + className, + style, + ...props +}: MaritimeLoaderProps) { + const cssVariables = { + ...(size ? { "--ema-size": toCssSize(size) } : {}), + ...(color ? { "--ema-color": color } : {}), + ...style, + } as CSSProperties; + + return ( + + + + + {label && {label}} + + ); +} + +export default MaritimeLoader; + +// how to use this component. +//
+// +//