fix(form): break drawer counter/dirty infinite-loop + add GlobeLoader + AppErrorBoundary
Maximum update depth exceeded showed на /dictionaries/$name когда RecordDrawer
открывался в create mode. Root cause:
1. SchemaDrivenForm useEffect deps включал onCounterChange/onDirtyChange.
2. Parent route передавал inline arrow (() => setDrawerCount({...})) —
новая identity каждый render.
3. setDrawerCount({visible, total}) создавал новый object literal даже
при тех же значениях → React re-render → effect fires → loop.
Fix:
- SchemaDrivenForm: ref-pattern для callback. useEffect subscribed только
на value deps (visibleFieldCount/totalFieldCount/dirtyCount), latest
callback хранится в ref.
- dataValues — useMemo с frozen EMPTY_DATA default чтобы не invalidate'ить
isFieldVisible memo каждый render.
Plus:
- GlobeLoader component — orbital globe spinner per Downloads/Globe loader
prototype. Pure SVG + CSS animations, без d3/topojson.
- LoadingBlock переключен с Loader2 на GlobeLoader (все usage обновляется
автоматически: RecordHistoryDrawer, dictionaries.$name editor loading,
search results, webhooks list, etc).
- AppErrorBoundary — friendly fallback вместо TanStack DefaultErrorComponent.
GlobeLoader (paused/dimmed) + 'Что-то пошло не так' + [Обновить][На главную].
Dev-only collapsible с raw stack trace.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
@@ -70,6 +70,10 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ajv = new Ajv({ allErrors: true, strict: false })
|
const ajv = new Ajv({ allErrors: true, strict: false })
|
||||||
|
|
||||||
|
// Stable empty-object literal — используется как default для dataValues
|
||||||
|
// чтобы не создавать новый `{}` на каждый render (deps memo invalidation).
|
||||||
|
const EMPTY_DATA: Record<string, unknown> = Object.freeze({})
|
||||||
addFormats(ajv)
|
addFormats(ajv)
|
||||||
|
|
||||||
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
||||||
@@ -149,7 +153,12 @@ export const SchemaDrivenForm = ({
|
|||||||
// search: substring match (case-insensitive) против key OR labelOf(key)
|
// search: substring match (case-insensitive) против key OR labelOf(key)
|
||||||
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
|
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
|
||||||
// Применяется только в sections layout (drawer use case).
|
// Применяется только в sections layout (drawer use case).
|
||||||
const dataValues = (mode === 'edit' ? defaultValues?.data : undefined) ?? {}
|
// useMemo чтобы default {} не создавал new object literal каждый render —
|
||||||
|
// иначе isFieldVisible useMemo ниже invalidate'ится → каскад re-renders.
|
||||||
|
const dataValues = useMemo(
|
||||||
|
() => (mode === 'edit' ? defaultValues?.data : undefined) ?? EMPTY_DATA,
|
||||||
|
[mode, defaultValues?.data],
|
||||||
|
)
|
||||||
const isFieldVisible = useMemo(() => {
|
const isFieldVisible = useMemo(() => {
|
||||||
const search = (searchFilter ?? '').trim().toLowerCase()
|
const search = (searchFilter ?? '').trim().toLowerCase()
|
||||||
return (key: string): boolean => {
|
return (key: string): boolean => {
|
||||||
@@ -183,9 +192,18 @@ export const SchemaDrivenForm = ({
|
|||||||
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
||||||
const visibleFieldCount =
|
const visibleFieldCount =
|
||||||
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
||||||
|
// Ref-pattern для callback: parent часто передаёт inline arrow
|
||||||
|
// (() => setState(...)) — identity меняется каждый render. Если включить
|
||||||
|
// callback в effect deps → effect fires → setState → re-render → loop
|
||||||
|
// ("Maximum update depth exceeded"). Держим latest callback в ref и
|
||||||
|
// вызываем по value-deps только. (review #infinite-loop-2026-05-11)
|
||||||
|
const onCounterChangeRef = useRef(onCounterChange)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCounterChange?.(visibleFieldCount, totalFieldCount)
|
onCounterChangeRef.current = onCounterChange
|
||||||
}, [visibleFieldCount, totalFieldCount, onCounterChange])
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
onCounterChangeRef.current?.(visibleFieldCount, totalFieldCount)
|
||||||
|
}, [visibleFieldCount, totalFieldCount])
|
||||||
|
|
||||||
const submit: SubmitHandler<FormValues> = (values) => {
|
const submit: SubmitHandler<FormValues> = (values) => {
|
||||||
if (values.validFrom && values.validTo) {
|
if (values.validFrom && values.validTo) {
|
||||||
@@ -243,9 +261,15 @@ export const SchemaDrivenForm = ({
|
|||||||
: 0
|
: 0
|
||||||
return top + dataDirty
|
return top + dataDirty
|
||||||
}, [formState.dirtyFields])
|
}, [formState.dirtyFields])
|
||||||
|
// Same ref-pattern as onCounterChange: parent's inline callback identity
|
||||||
|
// changes every render → loop. Subscribe to value (dirtyCount) only.
|
||||||
|
const onDirtyChangeRef = useRef(onDirtyChange)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(dirtyCount)
|
onDirtyChangeRef.current = onDirtyChange
|
||||||
}, [dirtyCount, onDirtyChange])
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChangeRef.current?.(dirtyCount)
|
||||||
|
}, [dirtyCount])
|
||||||
|
|
||||||
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
||||||
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRouter } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/ui'
|
||||||
|
import { GlobeLoader } from '@/ui/components/globe-loader'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppErrorBoundary — friendly UI который рендерится через TanStack Router
|
||||||
|
* `errorComponent` когда route выкидывает unhandled exception (включая
|
||||||
|
* "Maximum update depth exceeded" infinite loops).
|
||||||
|
*
|
||||||
|
* Юзер видит:
|
||||||
|
* - illustrated GlobeLoader (статичный paused)
|
||||||
|
* - "Что-то пошло не так. Попробуйте обновить страницу"
|
||||||
|
* - кнопки [Обновить] / [На главную]
|
||||||
|
* - dev-only collapsible с raw error (только в dev mode, не в prod)
|
||||||
|
*
|
||||||
|
* НЕ показывает raw stack trace в prod — это утечка деталей реализации
|
||||||
|
* и тревожит юзера.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppErrorBoundaryProps = {
|
||||||
|
error: Error
|
||||||
|
reset?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppErrorBoundary({ error, reset }: AppErrorBoundaryProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
// Vite dev mode → import.meta.env.DEV — показываем raw error для разработки.
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (reset) reset()
|
||||||
|
else router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHome = () => {
|
||||||
|
router.navigate({ to: '/dictionaries' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="min-h-[60vh] flex flex-col items-center justify-center px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="opacity-40 mb-6">
|
||||||
|
<GlobeLoader size={120} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-title-xl text-ink font-semibold mb-2">
|
||||||
|
{t('error.boundary.title', { defaultValue: 'Что-то пошло не так' })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-body text-mute max-w-md mb-6">
|
||||||
|
{t('error.boundary.description', {
|
||||||
|
defaultValue:
|
||||||
|
'Произошла непредвиденная ошибка при отображении страницы. Попробуйте обновить — обычно это помогает. Если повторится, сообщите нам.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="primary" size="sm" onClick={handleRetry}>
|
||||||
|
{t('error.boundary.retry', { defaultValue: 'Обновить' })}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleHome}>
|
||||||
|
{t('error.boundary.home', { defaultValue: 'На главную' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isDev && (
|
||||||
|
<div className="mt-8 w-full max-w-2xl text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDetails((v) => !v)}
|
||||||
|
className="text-cell text-mute hover:text-ink-2 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{showDetails ? 'Скрыть детали (dev)' : 'Показать детали (dev)'}
|
||||||
|
</button>
|
||||||
|
{showDetails && (
|
||||||
|
<pre className="mt-2 rounded-md border border-line bg-surface-2 p-3 text-mono text-cell text-ink-2 overflow-auto max-h-64 whitespace-pre-wrap">
|
||||||
|
{error.message}
|
||||||
|
{error.stack ? `\n\n${error.stack}` : ''}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|||||||
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar'
|
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar'
|
||||||
import { TopBar } from '@/components/layout/TopBar'
|
import { TopBar } from '@/components/layout/TopBar'
|
||||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
|
import { AppErrorBoundary } from '@/components/layout/AppErrorBoundary'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root layout (Stage 3.1 + 3.x mobile responsive):
|
* Root layout (Stage 3.1 + 3.x mobile responsive):
|
||||||
@@ -24,6 +25,9 @@ import { UpdateBanner } from '@/components/version/UpdateBanner'
|
|||||||
*/
|
*/
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
|
// Catch unhandled errors inside any route — show friendly UI instead of
|
||||||
|
// TanStack default "Something went wrong! Hide Error" + raw stack trace.
|
||||||
|
errorComponent: AppErrorBoundary,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
|||||||
@@ -374,6 +374,45 @@ body {
|
|||||||
color: var(--color-mute);
|
color: var(--color-mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === GlobeLoader keyframes — orbital spinner (src/ui/components/globe-loader.tsx).
|
||||||
|
* Класс .globe-loader на wrapper'е активирует вложенные .globe-* animations
|
||||||
|
* через стандартные CSS animation rules. Transform-only — GPU-композитятся. */
|
||||||
|
@keyframes ord-globe-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes ord-globe-pulse {
|
||||||
|
0%, 100% { opacity: 0.18; }
|
||||||
|
50% { opacity: 0.42; }
|
||||||
|
}
|
||||||
|
.globe-loader .globe-whirl {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
transform-box: fill-box;
|
||||||
|
animation: ord-globe-spin 3.6s linear infinite;
|
||||||
|
}
|
||||||
|
.globe-loader .globe-whirl-rev {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
transform-box: fill-box;
|
||||||
|
animation: ord-globe-spin 5.4s linear infinite reverse;
|
||||||
|
}
|
||||||
|
.globe-loader .globe-pulse {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
transform-box: fill-box;
|
||||||
|
animation: ord-globe-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.globe-loader .globe-sphere {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
transform-box: fill-box;
|
||||||
|
animation: ord-globe-spin 14s linear infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.globe-loader .globe-whirl,
|
||||||
|
.globe-loader .globe-whirl-rev,
|
||||||
|
.globe-loader .globe-pulse,
|
||||||
|
.globe-loader .globe-sphere {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === Legacy fallback: старые компоненты используют nstart token names
|
/* === Legacy fallback: старые компоненты используют nstart token names
|
||||||
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
|
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
|
||||||
* полная миграция. === */
|
* полная миграция. === */
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useId as useReactId } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobeLoader — orbital globe spinner per Downloads/Globe loader prototype.
|
||||||
|
*
|
||||||
|
* Чистый CSS/SVG (без d3 / topojson) — упрощённая sphere с graticule
|
||||||
|
* meridians + pulsing outer ring + два контр-вращающихся whirl arcs с tail
|
||||||
|
* gradient. Stylized в дизайн-системе: stroke=currentColor (берёт цвет ink
|
||||||
|
* из родителя — works на любом фоне, в т.ч. dark mode).
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* <GlobeLoader /> // 64px default
|
||||||
|
* <GlobeLoader size={120} /> // larger
|
||||||
|
* <GlobeLoader className="text-mute" /> // tint
|
||||||
|
*
|
||||||
|
* Performance: всё в SVG + CSS animations (transform / opacity) — paint-only,
|
||||||
|
* без JS frame loop. Безопасно держать одновременно на странице.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GlobeLoaderProps = {
|
||||||
|
/** Pixel size of the spinner square (default 64). */
|
||||||
|
size?: number
|
||||||
|
/** Optional label rendered below (text-body, мute). */
|
||||||
|
label?: React.ReactNode
|
||||||
|
/** Extra wrapper class — обычно text-ink/text-mute для tint. */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
||||||
|
// ID gradient unique per instance чтобы несколько loader'ов не конфликтовали
|
||||||
|
// через shared <defs id>. React 19 useId — SSR-safe + stable.
|
||||||
|
const id = useReactId().replace(/:/g, '')
|
||||||
|
const tailA = `globe-tail-a-${id}`
|
||||||
|
const tailB = `globe-tail-b-${id}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="loading"
|
||||||
|
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="globe-loader relative"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="60%" stopColor="currentColor" stopOpacity="0.18" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0.95" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={tailB} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="70%" stopColor="currentColor" stopOpacity="0.12" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0.55" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Pulsing outermost dashed ring */}
|
||||||
|
<g className="globe-pulse">
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="96"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.6"
|
||||||
|
strokeDasharray="1 4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Outer whirl: 270° tail arc + leading dot + ghost arc */}
|
||||||
|
<g className="globe-whirl">
|
||||||
|
<path
|
||||||
|
d="M 100 4 A 96 96 0 1 1 4 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailA})`}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="4" r="2.4" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M 100 12 A 88 88 0 0 1 188 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailB})`}
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Inner whirl, counter-rotating, tighter */}
|
||||||
|
<g className="globe-whirl-rev">
|
||||||
|
<path
|
||||||
|
d="M 100 18 A 82 82 0 1 1 18 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailA})`}
|
||||||
|
strokeWidth="1.1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.55"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Sphere + graticule. Без d3 — фиксированные meridians/parallels.
|
||||||
|
* Meridians = vertical ellipses with varying rx (orthographic-like
|
||||||
|
* projection приближение). Parallels = horizontal lines (clipped
|
||||||
|
* sphere implicit через circle clip). */}
|
||||||
|
<g className="globe-sphere">
|
||||||
|
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
{/* meridians at different rx values — looks like rotating projection */}
|
||||||
|
<g stroke="currentColor" strokeWidth="0.5" strokeOpacity="0.35" fill="none">
|
||||||
|
<ellipse cx="100" cy="100" rx="60" ry="60" />
|
||||||
|
<ellipse cx="100" cy="100" rx="45" ry="60" />
|
||||||
|
<ellipse cx="100" cy="100" rx="25" ry="60" />
|
||||||
|
<ellipse cx="100" cy="100" rx="5" ry="60" />
|
||||||
|
{/* parallels — horizontal lines clipped within the sphere */}
|
||||||
|
<line x1="44" y1="80" x2="156" y2="80" />
|
||||||
|
<line x1="40" y1="100" x2="160" y2="100" />
|
||||||
|
<line x1="44" y1="120" x2="156" y2="120" />
|
||||||
|
<line x1="55" y1="60" x2="145" y2="60" />
|
||||||
|
<line x1="55" y1="140" x2="145" y2="140" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{label && <span className="text-body text-mute">{label}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { GlobeLoader } from './globe-loader'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui}
|
* LoadingBlock — central spinner с подписью. Используется везде где
|
||||||
* LoadingBlock. Для inline placeholder используйте Skeleton.
|
* загружаются данные / окна. С 11.05.2026 заменили Loader2 spinner на
|
||||||
|
* GlobeLoader (orbital globe loader per /Users/zimin/Downloads/Globe loader).
|
||||||
*
|
*
|
||||||
* Size variants:
|
* Size variants:
|
||||||
* - sm: small inline spinner (py-4)
|
* - sm: small inline (py-4, 32px globe)
|
||||||
* - md: default centered (py-12)
|
* - md: default centered (py-12, 64px globe)
|
||||||
* - lg: prominent (py-20)
|
* - lg: prominent full-page (py-20, 96px globe)
|
||||||
*/
|
*/
|
||||||
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
@@ -16,9 +17,9 @@ export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIZES = {
|
const SIZES = {
|
||||||
sm: { wrapper: 'py-4', icon: 'size-3' },
|
sm: { wrapper: 'py-4', globe: 32 },
|
||||||
md: { wrapper: 'py-12', icon: 'size-5' },
|
md: { wrapper: 'py-12', globe: 64 },
|
||||||
lg: { wrapper: 'py-20', icon: 'size-8' },
|
lg: { wrapper: 'py-20', globe: 96 },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
|
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
|
||||||
@@ -28,14 +29,14 @@ export function LoadingBlock({ className, label, size = 'md', ...props }: Loadin
|
|||||||
role="status"
|
role="status"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center gap-2 text-mute',
|
'flex flex-col items-center justify-center gap-3 text-ink',
|
||||||
s.wrapper,
|
s.wrapper,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Loader2 className={cn('animate-spin text-accent', s.icon)} />
|
<GlobeLoader size={s.globe} />
|
||||||
{label && <span className="text-body">{label}</span>}
|
{label && <span className="text-body text-mute">{label}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export { Panel, type PanelProps } from './components/panel'
|
|||||||
export { PageHeader, type PageHeaderProps } from './components/page-header'
|
export { PageHeader, type PageHeaderProps } from './components/page-header'
|
||||||
export { EmptyState, type EmptyStateProps } from './components/empty-state'
|
export { EmptyState, type EmptyStateProps } from './components/empty-state'
|
||||||
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
|
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
|
||||||
|
export { GlobeLoader, type GlobeLoaderProps } from './components/globe-loader'
|
||||||
|
|
||||||
// === Tables ===
|
// === Tables ===
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user