diff --git a/components/.DS_Store b/components/.DS_Store index 346b9cb..76548a1 100644 Binary files a/components/.DS_Store and b/components/.DS_Store differ diff --git a/components/custom-loader/.DS_Store b/components/custom-loader/.DS_Store new file mode 100644 index 0000000..d9fe7bc Binary files /dev/null and b/components/custom-loader/.DS_Store differ diff --git a/components/custom-loader/README.md b/components/custom-loader/README.md new file mode 100644 index 0000000..273527c --- /dev/null +++ b/components/custom-loader/README.md @@ -0,0 +1,247 @@ +# ⥠Custom Loader Component (Retool Custom Component) + +A highly customizable loading component for Retool applications featuring multiple loader types, animated progress indicators, skeleton screens, overlay modes, fullscreen loading states, and automatic query progress tracking. + +--- + +## ð Features + +* âģ Multiple loader types +* ðŊ Progress tracking with percentage indicators +* ð Automatic query completion calculation +* ðĶī Table, Dashboard, and Form Skeleton loaders +* ð Multiple animated spinner styles +* ð Step-by-step workflow visualization +* ðĨ Inline, Overlay, and Fullscreen display modes +* ðĄ Rotating loading tips +* â Success state handling +* â Error state handling +* ð Empty state handling +* ðĻ Light, Dark, and Auto themes +* ðą Fully responsive design +* ⥠Optimized for Retool applications + +--- + +## ðĶ Inputs + +| Name | Type | Description | +| ------------------ | ------- | ------------------------------------------------------------- | +| `loaderStateInput` | String | Current loader state (`loading`, `success`, `error`, `empty`) | +| `loaderType` | String | Loader type to display | +| `theme` | String | Theme mode (`auto`, `light`, `dark`) | +| `overlayMode` | String | Display mode (`inline`, `overlay`, `fullscreen`) | +| `title` | String | Loader title text | +| `subtitle` | String | Loader subtitle text | +| `showProgress` | Boolean | Show progress bar | +| `spinnerStyle` | String | Spinner animation style | +| `progress` | Number | Manual progress value (0-100) | +| `errorMessage` | String | Message displayed for error state | +| `emptyMessage` | String | Message displayed for empty state | +| `tips` | Array | Rotating tips array | +| `steps` | Array | Step objects for step loader | +| `queryStates` | Object | Query completion status object | +| `hideDelay` | Number | Auto-hide delay in milliseconds | + +--- + +## ð Loader Types + +Supported loader types: + +spinner +progress +steps +tableSkeleton +dashboardSkeleton +formSkeleton + + +### Spinner Loader + +Traditional animated loading indicator. + +### Progress Loader + +Displays animated progress bar with percentage. + +### Steps Loader + +Shows workflow progress using completed and pending steps. + +### Table Skeleton + +Simulates table rows while data loads. + +### Dashboard Skeleton + +Simulates dashboard KPIs, charts, and tables. + +### Form Skeleton + +Simulates form fields during loading. + +--- + +## ðĻ Spinner Styles + +Supported spinner styles: + +circle +dualRing +pulse +bars +ripple +heartbeat +cubeGrid +triangle +wave +dots + + +--- + +## ðĨ Display Modes + +| Mode | Description | +| ------------ | ---------------------------------- | +| `inline` | Displays inside the component area | +| `overlay` | Displays above component content | +| `fullscreen` | Covers the entire viewport | + +--- + +## ð Automatic Progress Tracking + +Pass query completion states: + +{ + "usersLoaded": true, + "ordersLoaded": true, + "reportsLoaded": false, + "analyticsLoaded": false +} + + +The component automatically calculates: 50% | completion progress. + +--- + +## ð Steps Configuration + +Example steps array: + +[ + { + "label": "Fetch Users", + "completed": true + }, + { + "label": "Load Reports", + "completed": true + }, + { + "label": "Generate Dashboard", + "completed": false + } +] + + +--- + +## ðĄ Loading Tips + +Example tips array: + +[ + "Loading dashboard data...", + "Fetching latest records...", + "Preparing visualizations...", + "Almost ready..." +] + + +Tips rotate automatically every few seconds. + +--- + +## ðĪ Outputs + +This component is designed primarily as a visual state component and does not expose output values. + +Progress, state transitions, and visibility are controlled through component inputs. + +--- + +## ðĨ Example Usage + +### Query Loading State + +{{ getUsers.isFetching ? "loading" : "success" }} + + +### Error Handling + +{{ getUsers.error ? "error" : "loading" }} + + +### Empty State + +{{ getUsers.data?.length === 0 ? "empty" : "success" }} + + +### Multi Query Progress + +{ + users: !getUsers.isFetching, + reports: !getReports.isFetching, + analytics: !getAnalytics.isFetching, + dashboard: !getDashboard.isFetching +} + + +--- + +## ðŊ Use Cases + +* Dashboard Loading Screens +* Analytics Applications +* Report Generation +* API Request Tracking +* Data Synchronization +* Workflow Automation +* Multi-Step Processes +* Table Loading States +* Form Submissions +* Background Data Processing + +--- + +## âïļ Recommended Configuration + +### Dashboard Loading + +Loader Type: dashboardSkeleton +Display Mode: overlay +Theme: auto + + +### API Progress Tracking + +Loader Type: progress +Show Progress: true + + +### Workflow Processing + +Loader Type: steps +Spinner Style: cubeGrid + + +--- + +## ðĻâðŧ Author + +**Widle Studio LLP** + +Built specifically for Retool applications to provide modern, customizable, and production-ready loading experiences. diff --git a/components/custom-loader/metadata.json b/components/custom-loader/metadata.json new file mode 100644 index 0000000..62c296d --- /dev/null +++ b/components/custom-loader/metadata.json @@ -0,0 +1,18 @@ +{ + "id": "custom-loader-component", + "title": "Custom Loader", + "author": "@widlestudiollp", + "shortDescription": "A highly customizable loading component for Retool featuring spinners, progress indicators, skeleton screens, step tracking, overlay and fullscreen modes, dynamic query-state integration, success/error states, and responsive design.", + "tags": [ + "Loader", + "Loading State", + "Progress", + "Skeleton", + "UX", + "Retool", + "Dashboard", + "Spinner", + "Overlay", + "Fullscreen" + ] +} \ No newline at end of file diff --git a/components/custom-loader/package.json b/components/custom-loader/package.json new file mode 100644 index 0000000..8128cf8 --- /dev/null +++ b/components/custom-loader/package.json @@ -0,0 +1,49 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "uuid": "^13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-scroll-to-bottom": "^4.2.5", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "CustomLoader", + "label": "Custom Loader", + "description": "Custom Loader with auto theme and dsplay options with custom loading effects.", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/custom-loader/preview.png b/components/custom-loader/preview.png new file mode 100644 index 0000000..8869572 Binary files /dev/null and b/components/custom-loader/preview.png differ diff --git a/components/custom-loader/src/component/LoaderComponent.css b/components/custom-loader/src/component/LoaderComponent.css new file mode 100644 index 0000000..eba0fb8 --- /dev/null +++ b/components/custom-loader/src/component/LoaderComponent.css @@ -0,0 +1,825 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +.loader-host { + width: 100%; + min-height: 300px; + position: relative; +} + +.loader-root { + width: 100%; + min-width: 100%; + min-height: 100%; + + display: flex; + flex-direction: column; + + overflow: hidden; + + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.loader-root.inline { + width: 100%; +} + +.loader-root.overlay { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + + display: flex; + align-items: center; + justify-content: center; +} + +.loader-root.light.overlay, +.loader-root.auto.overlay { + background: rgba(255, 255, 255, .75); +} + +.loader-root.dark.overlay { + background: rgba(0, 0, 0, .55); +} + +.loader-root.fullscreen { + position: fixed; + + top: 0; + left: 0; + width: 100vw; + height: 100vh; + + z-index: 999999; + + display: flex; + align-items: center; + justify-content: center; + + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.loader-root.light { + --bg: #ffffff; + --bg-secondary: #f8fafc; + --border: #e2e8f0; + --text: #0f172a; + --muted: #64748b; + --primary: #2563eb; + --primary-light: #60a5fa; + --success: #10b981; + --error: #ef4444; + --shadow: + 0 10px 30px rgba(0, 0, 0, 0.08); +} + +.loader-root.dark { + --bg: #111827; + --bg-secondary: #1f2937; + --border: #374151; + --text: #f9fafb; + --muted: #94a3b8; + --primary: #3b82f6; + --primary-light: #60a5fa; + --success: #10b981; + --error: #ef4444; + --shadow: + 0 10px 40px rgba(0, 0, 0, 0.35); +} + +@media (prefers-color-scheme: dark) { + .loader-root.auto { + --bg: #111827; + --bg-secondary: #1f2937; + --border: #374151; + --text: #f9fafb; + --muted: #94a3b8; + --primary: #3b82f6; + --primary-light: #60a5fa; + --success: #10b981; + --error: #ef4444; + } +} + +@media (prefers-color-scheme: light) { + .loader-root.auto { + --bg: #ffffff; + --bg-secondary: #f8fafc; + --border: #e2e8f0; + --text: #0f172a; + --muted: #64748b; + --primary: #2563eb; + --primary-light: #60a5fa; + --success: #10b981; + --error: #ef4444; + } +} + +.loader-container { + width: 100%; + min-height: 100%; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + padding: 24px; +} + +.fullscreen .loader-container { + width: min(720px, 92vw); + + position: absolute; + top: 50%; + left: 50%; + + transform: + translate(-50%, -50%); +} + +.loader-container h2 { + margin: 20px 0 8px; + + font-size: 24px; + font-weight: 700; + + color: var(--text); +} + +.loader-container p { + margin: 0; + + color: var(--muted); + + line-height: 1.6; +} + +.spinner { + width: 72px; + height: 72px; + + border-radius: 50%; + + border: 5px solid transparent; + border-top-color: var(--primary); + + animation: + spinnerRotate 1s linear infinite; + + position: relative; +} + +.spinner::before { + content: ""; + + position: absolute; + inset: 8px; + + border-radius: 50%; + + border: 4px solid transparent; + border-top-color: var(--primary-light); + + animation: + spinnerRotateReverse 1.5s linear infinite; +} + +.spinner.small { + width: 60px; + height: 60px; +} + +.spinner.small::before { + inset: 7px; +} + +@keyframes spinnerRotate { + to { + transform: rotate(360deg); + } +} + +@keyframes spinnerRotateReverse { + to { + transform: rotate(-360deg); + } +} + +.progress-track { + width: min(500px, 100%); + height: 12px; + + margin-top: 24px; + + background: var(--bg-secondary); + + border-radius: 999px; + + overflow: hidden; +} + +.progress-fill { + height: 100%; + + border-radius: inherit; + + background: + linear-gradient(90deg, + var(--primary), + var(--primary-light)); + + transition: + width 0.4s ease; +} + +.progress-value { + margin-top: 12px; + + font-size: 14px; + font-weight: 700; + + color: var(--primary); +} + +.tip-box { + margin-top: 24px; + + width: min(500px, 100%); + + padding: 14px 16px; + + border-radius: 12px; + + background: + rgba(37, 99, 235, 0.08); + + color: var(--muted); + + font-size: 14px; + + animation: + fadeIn 0.3s ease; +} + +.steps-list { + width: min(500px, 100%); + + margin-top: 24px; + + display: flex; + flex-direction: column; + gap: 10px; +} + +.step-row { + display: flex; + align-items: center; + + gap: 12px; + + padding: 12px 16px; + + border-radius: 12px; + + background: var(--bg-secondary); + + color: var(--muted); + + text-align: left; +} + +.step-row.completed { + color: var(--success); + font-weight: 600; +} + +.state-card { + display: flex; + flex-direction: column; + align-items: center; +} + +.success-icon, +.error-icon, +.empty-icon { + width: 96px; + height: 96px; + + display: flex; + align-items: center; + justify-content: center; + + border-radius: 50%; + + font-size: 36px; + font-weight: 700; + + margin-bottom: 20px; +} + +.success-icon { + background: + rgba(16, 185, 129, 0.15); + + color: var(--success); +} + +.error-icon { + background: + rgba(239, 68, 68, 0.15); + + color: var(--error); +} + +.empty-icon { + background: + rgba(100, 116, 139, 0.15); + + color: var(--muted); +} + +.table-skeleton { + width: 100%; + max-width: 900px; + + display: flex; + flex-direction: column; + + gap: 10px; +} + +.table-row { + height: 48px; + + border-radius: 10px; + + background: + linear-gradient(90deg, + var(--bg-secondary) 25%, + rgba(255, 255, 255, 0.4) 50%, + var(--bg-secondary) 75%); + + background-size: 200% 100%; + + animation: + shimmer 1.4s infinite linear; +} + +.form-skeleton { + width: min(650px, 100%); + + display: flex; + flex-direction: column; + + gap: 18px; +} + +.form-line { + height: 52px; + + border-radius: 12px; + + background: + linear-gradient(90deg, + var(--bg-secondary) 25%, + rgba(255, 255, 255, 0.4) 50%, + var(--bg-secondary) 75%); + + background-size: 200% 100%; + + animation: + shimmer 1.4s infinite linear; +} + +.dashboard-skeleton { + width: 100%; + max-width: 1000px; +} + +.kpi-row { + display: grid; + + grid-template-columns: + repeat(4, 1fr); + + gap: 16px; +} + +.kpi-card { + height: 120px; + + border-radius: 16px; + + background: + linear-gradient(90deg, + var(--bg-secondary) 25%, + rgba(255, 255, 255, 0.4) 50%, + var(--bg-secondary) 75%); + + background-size: 200% 100%; + + animation: + shimmer 1.4s infinite linear; +} + +.chart-skeleton { + height: 260px; + + margin-top: 18px; + margin-bottom: 18px; + + border-radius: 18px; + + background: + linear-gradient(90deg, + var(--bg-secondary) 25%, + rgba(255, 255, 255, 0.4) 50%, + var(--bg-secondary) 75%); + + background-size: 200% 100%; + + animation: + shimmer 1.4s infinite linear; +} + +@keyframes shimmer { + 0% { + background-position: + 200% 0; + } + + 100% { + background-position: + -200% 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: + translateY(4px); + } + + to { + opacity: 1; + transform: + translateY(0); + } +} + +@media (max-width: 1024px) { + + .kpi-row { + grid-template-columns: + repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + + .loader-container { + padding: 24px; + } + + .loader-container h2 { + font-size: 20px; + } + + .spinner { + width: 64px; + height: 64px; + } + + .success-icon, + .error-icon, + .empty-icon { + width: 80px; + height: 80px; + font-size: 30px; + } + + .chart-skeleton { + height: 200px; + } +} + +@media (max-width: 576px) { + + .loader-container { + padding: 20px; + border-radius: 16px; + } + + .kpi-row { + grid-template-columns: 1fr; + } + + .spinner { + width: 56px; + height: 56px; + } + + .loader-container h2 { + font-size: 18px; + } + + .loader-container p { + font-size: 14px; + } + + .steps-list { + gap: 8px; + } + + .step-row { + padding: 10px 12px; + } + + .chart-skeleton { + height: 160px; + } +} + +.spinner-dual-ring { + width: 72px; + height: 72px; + border: 5px solid transparent; + border-top-color: var(--primary); + border-bottom-color: var(--primary); + border-radius: 50%; + animation: spinnerRotate 1s linear infinite; +} + +.spinner-pulse { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--primary); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(.8); + opacity: .6; + } + + 50% { + transform: scale(1); + opacity: 1; + } + + 100% { + transform: scale(.8); + opacity: .6; + } +} + +.spinner-bars { + display: flex; + gap: 4px; + align-items: end; + height: 60px; +} + +.spinner-bars span { + width: 8px; + height: 20px; + background: var(--primary); + animation: bars 1s infinite; +} + +.spinner-bars span:nth-child(2) { + animation-delay: .1s; +} + +.spinner-bars span:nth-child(3) { + animation-delay: .2s; +} + +.spinner-bars span:nth-child(4) { + animation-delay: .3s; +} + +@keyframes bars { + 50% { + height: 60px; + } +} + +.spinner-dual-ring.small, +.spinner-pulse.small { + width: 60px; + height: 60px; +} + +.spinner-ripple { + position: relative; + width: 72px; + height: 72px; +} + +.spinner-ripple div { + position: absolute; + inset: 0; + border: 4px solid var(--primary); + border-radius: 50%; + animation: ripple 1.5s infinite; +} + +.spinner-ripple div:nth-child(2) { + animation-delay: .75s; +} + +@keyframes ripple { + from { + transform: scale(.2); + opacity: 1; + } + + to { + transform: scale(1); + opacity: 0; + } +} + +.spinner-dots { + display: flex; + gap: 8px; +} + +.spinner-dots span { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--primary); + animation: dots .8s infinite alternate; +} + +.spinner-dots span:nth-child(2) { + animation-delay: .2s; +} + +.spinner-dots span:nth-child(3) { + animation-delay: .4s; +} + +@keyframes dots { + to { + transform: translateY(-10px); + } +} + +.spinner-wave { + display: flex; + gap: 4px; + align-items: center; + height: 60px; +} + +.spinner-wave span { + width: 6px; + height: 20px; + background: var(--primary); + animation: wave 1s infinite; +} + +.spinner-wave span:nth-child(2) { + animation-delay: .1s; +} + +.spinner-wave span:nth-child(3) { + animation-delay: .2s; +} + +.spinner-wave span:nth-child(4) { + animation-delay: .3s; +} + +.spinner-wave span:nth-child(5) { + animation-delay: .4s; +} + +@keyframes wave { + 50% { + height: 60px; + } +} + +.spinner-grid { + width: 72px; + height: 72px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.spinner-grid span { + background: var(--primary); + animation: gridPulse 1.2s infinite; +} + +@keyframes gridPulse { + 50% { + opacity: .3; + } +} + +.spinner-triangle { + width: 0; + height: 0; + border-left: 36px solid transparent; + border-right: 36px solid transparent; + border-bottom: 62px solid var(--primary); + animation: spinnerRotate 1s linear infinite; +} + +.spinner-heart { + width: 60px; + height: 60px; + background: var(--primary); + transform: rotate(45deg); + animation: pulse 1s infinite; + position: relative; +} + +.spinner-heart::before, +.spinner-heart::after { + content: ""; + position: absolute; + width: 60px; + height: 60px; + background: var(--primary); + border-radius: 50%; +} + +.spinner-heart::before { + top: -30px; +} + +.spinner-heart::after { + left: -30px; +} + +.spinner-ripple.small, +.spinner-grid.small, +.spinner-heart.small { + width: 60px; + height: 60px; +} + +.spinner-triangle.small { + border-left-width: 30px; + border-right-width: 30px; + border-bottom-width: 52px; +} + +.spinner-grid span:nth-child(1) { + animation-delay: .1s; +} + +.spinner-grid span:nth-child(2) { + animation-delay: .2s; +} + +.spinner-grid span:nth-child(3) { + animation-delay: .3s; +} + +.spinner-grid span:nth-child(4) { + animation-delay: .4s; +} + +.spinner-grid span:nth-child(5) { + animation-delay: .5s; +} + +.spinner-grid span:nth-child(6) { + animation-delay: .6s; +} + +.spinner-grid span:nth-child(7) { + animation-delay: .7s; +} + +.spinner-grid span:nth-child(8) { + animation-delay: .8s; +} + +.spinner-grid span:nth-child(9) { + animation-delay: .9s; +} \ No newline at end of file diff --git a/components/custom-loader/src/component/LoaderComponent.tsx b/components/custom-loader/src/component/LoaderComponent.tsx new file mode 100644 index 0000000..97792a3 --- /dev/null +++ b/components/custom-loader/src/component/LoaderComponent.tsx @@ -0,0 +1,785 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import "./LoaderComponent.css"; + +type LoaderState = + | "loading" + | "success" + | "error" + | "empty"; + +type LoaderType = + | "spinner" + | "progress" + | "steps" + | "tableSkeleton" + | "dashboardSkeleton" + | "formSkeleton"; + +type SpinnerStyle = + | "circle" + | "dualRing" + | "pulse" + | "bars" + | "ripple" + | "heartbeat" + | "cubeGrid" + | "triangle" + | "wave" + | "dots"; + +interface StepItem { + label: string; + completed?: boolean; +} + +export const LoaderComponent: React.FC = () => { + const [loaderStateInput] = + Retool.useStateString({ + name: "loaderStateInput", + label: "Loader State", + description: + "Bind dynamic value like {{query.isFetching ? 'loading' : 'success'}}", + initialValue: "loading" + }); + + const [loaderType] = + Retool.useStateEnumeration({ + name: "loaderType", + enumDefinition: [ + "spinner", + "progress", + "steps", + "tableSkeleton", + "dashboardSkeleton", + "formSkeleton" + ], + initialValue: "spinner", + enumLabels: { + spinner: "Spinner", + progress: "Progress", + steps: "Steps", + tableSkeleton: "Table Skeleton", + dashboardSkeleton: "Dashboard Skeleton", + formSkeleton: "Form Skeleton" + }, + inspector: "select", + label: "Loader Type", + description: + "Choose loader style" + }); + + const [theme] = Retool.useStateEnumeration({ + name: "theme", + label: "Theme", + description: + "Choose light, dark or auto theme", + inspector: "select", + initialValue: "auto", + enumDefinition: [ + "auto", + "light", + "dark" + ], + enumLabels: { + auto: "Auto", + light: "Light", + dark: "Dark" + } + }); + + const [overlayMode] = Retool.useStateEnumeration({ + name: "overlayMode", + label: "Display Mode", + description: + "Choose how the loader is displayed", + inspector: "select", + initialValue: "inline", + enumDefinition: [ + "inline", + "overlay", + "fullscreen" + ], + enumLabels: { + inline: "Inline", + overlay: "Overlay", + fullscreen: "Fullscreen" + } + }); + + const [title] = Retool.useStateString({ + name: "title", + label: "Title", + description: + "Loader heading text", + initialValue: "Loading Data" + }); + + const [subtitle] = Retool.useStateString({ + name: "subtitle", + label: "Subtitle", + description: + "Loader description text", + initialValue: "Please wait while data is loading..." + }); + + const [showProgress] = Retool.useStateBoolean({ + name: "showProgress", + label: "Show Progress", + description: + "Display progress bar", + inspector: "checkbox", + initialValue: true + }); + + const [spinnerStyle] = + Retool.useStateEnumeration({ + name: "spinnerStyle", + label: "Spinner Style", + description: + "Choose spinner animation style", + initialValue: "circle", + inspector: "select", + enumDefinition: [ + "circle", + "dualRing", + "pulse", + "bars", + "ripple", + "heartbeat", + "cubeGrid", + "triangle", + "wave", + "dots" + ], + enumLabels: { + circle: "Circle Loader", + dualRing: "Dual Ring", + pulse: "Pulse Loader", + bars: "Bars Loader", + ripple: "Ripple Loader", + heartbeat: "Heartbeat", + cubeGrid: "Cube Grid", + triangle: "Triangle", + wave: "Wave Loader", + dots: "Rotating Dots" + } + }); + + const [manualProgress] = + Retool.useStateNumber({ + name: "progress", + label: "Progress", + description: "0 - 100", + inspector: "text", + initialValue: 0 + }); + + const [errorMessage] = Retool.useStateString({ + name: "errorMessage", + label: "Error Message", + description: + "Message shown when state is error", + initialValue: "Failed to load data." + }); + + const [emptyMessage] = Retool.useStateString({ + name: "emptyMessage", + label: "Empty Message", + description: + "Message shown when state is empty", + initialValue: "No records found." + }); + + const [tips] = + Retool.useStateArray({ + name: "tips", + label: "Tips Array", + description: + "Array of rotating tips", + inspector: "text" + }); + + const [steps] = + Retool.useStateArray({ + name: "steps", + label: "Steps", + description: + "Array of step objects", + inspector: "text" + }); + + const [queryStates] = + Retool.useStateObject({ + name: "queryStates", + label: "Query Status Object", + description: + "Object used to calculate progress automatically", + inspector: "text" + }); + + const [hideDelay] = + Retool.useStateNumber({ + name: "hideDelay", + label: "Hide Delay", + description: + "Milliseconds before loader hides", + inspector: "text", + initialValue: 3000 + }); + + Retool.useComponentSettings({ + defaultWidth: 6, + defaultHeight: 8, + }); + + const loaderState = ( + loaderStateInput || "loading" + ).toLowerCase() as LoaderState; + + const spinnerStyleValue = + spinnerStyle as SpinnerStyle; + + const [animatedProgress, setAnimatedProgress] = + useState(0); + + const [loadingStartedAt] = + useState(() => Date.now()); + + const calculatedProgress = + useMemo(() => { + + if ( + queryStates && + typeof queryStates === "object" && + Object.keys(queryStates).length > 0 + ) { + + const total = + Object.keys(queryStates).length; + + const completed = + Object.values(queryStates) + .filter(Boolean).length; + + return Math.round( + (completed / total) * 100 + ); + } + + if ( + manualProgress > 0 + ) { + return Math.max( + 0, + Math.min( + 100, + manualProgress + ) + ); + } + + if ( + loaderState === "loading" + ) { + return animatedProgress; + } + + return 100; + + }, [ + queryStates, + manualProgress, + animatedProgress, + loaderState + ]); + + const [tipIndex, setTipIndex] = + useState(0); + + const [isVisible, setIsVisible] = + useState(true); + + useEffect(() => { + + if ( + !Array.isArray(tips) || + tips.length === 0 + ) return; + + const interval = + setInterval(() => { + + setTipIndex((prev) => + (prev + 1) % tips.length + ); + + }, 3000); + + return () => + clearInterval(interval); + + }, [tips]); + + useEffect(() => { + + if (loaderState !== "loading") { + return; + } + + setAnimatedProgress(5); + + const interval = setInterval(() => { + + setAnimatedProgress(prev => { + + if (prev >= 90) { + return 90; + } + + const increment = + prev < 30 + ? 3 + : prev < 60 + ? 2 + : 1; + + return Math.min( + 90, + prev + increment + ); + + }); + + }, 150); + + return () => + clearInterval(interval); + + }, [loaderState]); + + useEffect(() => { + + if ( + loaderState === "success" || + loaderState === "error" || + loaderState === "empty" + ) { + + const MIN_LOADING_TIME = 1000; + + const elapsed = + Date.now() - loadingStartedAt; + + const remaining = + Math.max( + 0, + MIN_LOADING_TIME - elapsed + ); + + const progressTimer = + setTimeout(() => { + + setAnimatedProgress(100); + + const hideTimer = + setTimeout(() => { + setIsVisible(false); + }, hideDelay); + + (window as any).__loaderHideTimer = + hideTimer; + + }, remaining); + + return () => { + + clearTimeout(progressTimer); + + clearTimeout( + (window as any).__loaderHideTimer + ); + + }; + } + + setIsVisible(true); + + }, [ + loaderState, + hideDelay, + loadingStartedAt + ]); + + const rootClass = useMemo(() => { + + let cls = "loader-root"; + + cls += ` ${theme}`; + + cls += ` ${overlayMode}`; + + return cls; + + }, [theme, overlayMode]); + + const renderSuccess = () => ( +
+ Data loaded successfully. +
+ +{errorMessage}
+ +{emptyMessage}
+ +{subtitle}
+ + {Array.isArray(tips) && + tips.length > 0 && ( +{subtitle}
+ + {showProgress && ( + <> +{subtitle}
+ +