@asteby/metacore-runtime-react 18.16.1 → 18.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ // Shared widget formatting + accent theming. Reuses the same Intl-based
2
+ // approach as the table cells (dynamic-columns) so dashboard numbers read
3
+ // identically to the grids: org currency + locale, compact notation, percent.
4
+
5
+ import type { WidgetAccent, WidgetFormat } from '../dashboard-types'
6
+
7
+ export interface WidgetFormatCtx {
8
+ format?: WidgetFormat
9
+ locale?: string
10
+ currency?: string
11
+ }
12
+
13
+ /**
14
+ * Formats a scalar/series value with the widget's declared format. `currency`
15
+ * uses the org currency + locale (same fallback chain as table cells); `percent`
16
+ * treats the value as a fraction (0.142 → "14.2%"); `compact` uses Intl compact
17
+ * notation (1_200 → "1.2K").
18
+ */
19
+ export function formatWidgetValue(
20
+ value: number,
21
+ { format = 'number', locale, currency }: WidgetFormatCtx,
22
+ ): string {
23
+ if (value === null || value === undefined || Number.isNaN(value)) return '—'
24
+ const loc = locale || undefined
25
+ switch (format) {
26
+ case 'currency':
27
+ return new Intl.NumberFormat(loc, {
28
+ style: 'currency',
29
+ currency: currency || 'USD',
30
+ maximumFractionDigits: 2,
31
+ }).format(value)
32
+ case 'percent':
33
+ return new Intl.NumberFormat(loc, {
34
+ style: 'percent',
35
+ minimumFractionDigits: 0,
36
+ maximumFractionDigits: 1,
37
+ }).format(value)
38
+ case 'compact':
39
+ return new Intl.NumberFormat(loc, {
40
+ notation: 'compact',
41
+ maximumFractionDigits: 1,
42
+ }).format(value)
43
+ case 'number':
44
+ default:
45
+ return new Intl.NumberFormat(loc, {
46
+ maximumFractionDigits: 2,
47
+ }).format(value)
48
+ }
49
+ }
50
+
51
+ /** Axis-tick formatter — always compact so charts stay legible. */
52
+ export function formatAxisTick(
53
+ value: number,
54
+ { format, locale, currency }: WidgetFormatCtx,
55
+ ): string {
56
+ const loc = locale || undefined
57
+ if (format === 'currency') {
58
+ return new Intl.NumberFormat(loc, {
59
+ style: 'currency',
60
+ currency: currency || 'USD',
61
+ notation: 'compact',
62
+ maximumFractionDigits: 1,
63
+ }).format(value)
64
+ }
65
+ if (format === 'percent') {
66
+ return new Intl.NumberFormat(loc, {
67
+ style: 'percent',
68
+ maximumFractionDigits: 0,
69
+ }).format(value)
70
+ }
71
+ return new Intl.NumberFormat(loc, {
72
+ notation: 'compact',
73
+ maximumFractionDigits: 1,
74
+ }).format(value)
75
+ }
76
+
77
+ /** Formats the compare delta fraction (0.142 → "+14.2%"). */
78
+ export function formatDelta(delta: number, locale?: string): string {
79
+ const sign = delta > 0 ? '+' : ''
80
+ return (
81
+ sign +
82
+ new Intl.NumberFormat(locale || undefined, {
83
+ style: 'percent',
84
+ minimumFractionDigits: 0,
85
+ maximumFractionDigits: 1,
86
+ }).format(delta)
87
+ )
88
+ }
89
+
90
+ // --- Accent theming -------------------------------------------------------
91
+ // Tailwind tokens scanned in THIS package's build (the renderers live in the
92
+ // SDK, not the federated host), so static utility classes are safe. We avoid
93
+ // host-dependent arbitrary values. Each accent maps to a small bundle of
94
+ // classes for the icon chip, the chart stroke/fill and the progress/list bar.
95
+
96
+ export interface AccentClasses {
97
+ /** Icon chip background + foreground. */
98
+ chip: string
99
+ /** Text color for accented labels. */
100
+ text: string
101
+ /** Solid bar/fill background (progress, list proportion). */
102
+ bar: string
103
+ /** Soft track background behind a bar. */
104
+ track: string
105
+ /** Hex-ish CSS color used for recharts stroke/fill (var-based). */
106
+ chartVar: string
107
+ }
108
+
109
+ const ACCENTS: Record<WidgetAccent, AccentClasses> = {
110
+ emerald: {
111
+ chip: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
112
+ text: 'text-emerald-600 dark:text-emerald-400',
113
+ bar: 'bg-emerald-500',
114
+ track: 'bg-emerald-500/15',
115
+ chartVar: 'var(--color-widget-emerald, #10b981)',
116
+ },
117
+ sky: {
118
+ chip: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
119
+ text: 'text-sky-600 dark:text-sky-400',
120
+ bar: 'bg-sky-500',
121
+ track: 'bg-sky-500/15',
122
+ chartVar: 'var(--color-widget-sky, #0ea5e9)',
123
+ },
124
+ violet: {
125
+ chip: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
126
+ text: 'text-violet-600 dark:text-violet-400',
127
+ bar: 'bg-violet-500',
128
+ track: 'bg-violet-500/15',
129
+ chartVar: 'var(--color-widget-violet, #8b5cf6)',
130
+ },
131
+ amber: {
132
+ chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
133
+ text: 'text-amber-600 dark:text-amber-400',
134
+ bar: 'bg-amber-500',
135
+ track: 'bg-amber-500/15',
136
+ chartVar: 'var(--color-widget-amber, #f59e0b)',
137
+ },
138
+ rose: {
139
+ chip: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
140
+ text: 'text-rose-600 dark:text-rose-400',
141
+ bar: 'bg-rose-500',
142
+ track: 'bg-rose-500/15',
143
+ chartVar: 'var(--color-widget-rose, #f43f5e)',
144
+ },
145
+ slate: {
146
+ chip: 'bg-slate-500/10 text-slate-600 dark:text-slate-300',
147
+ text: 'text-slate-600 dark:text-slate-300',
148
+ bar: 'bg-slate-500',
149
+ track: 'bg-slate-500/15',
150
+ chartVar: 'var(--color-widget-slate, #64748b)',
151
+ },
152
+ }
153
+
154
+ const DEFAULT_ACCENT: WidgetAccent = 'sky'
155
+
156
+ /** Resolves the accent class bundle, defaulting to `sky`. */
157
+ export function accentClasses(accent?: WidgetAccent): AccentClasses {
158
+ return ACCENTS[accent ?? DEFAULT_ACCENT] ?? ACCENTS[DEFAULT_ACCENT]
159
+ }
160
+
161
+ /**
162
+ * A categorical palette for multi-series charts (pie/donut/bar by bucket).
163
+ * Cycles the accent chart vars so slices stay theme-aware + dark-mode safe.
164
+ */
165
+ export const CHART_PALETTE: string[] = [
166
+ ACCENTS.sky.chartVar,
167
+ ACCENTS.emerald.chartVar,
168
+ ACCENTS.violet.chartVar,
169
+ ACCENTS.amber.chartVar,
170
+ ACCENTS.rose.chartVar,
171
+ ACCENTS.slate.chartVar,
172
+ ]
173
+
174
+ /** Picks a palette color by index, wrapping around. */
175
+ export function paletteColor(index: number): string {
176
+ return CHART_PALETTE[index % CHART_PALETTE.length]
177
+ }
178
+
179
+ /** Grid/axis muted color from the theme (works in light + dark). */
180
+ export const CHART_MUTED = 'var(--muted-foreground, #94a3b8)'
181
+ export const CHART_GRID = 'var(--border, #e2e8f0)'
@@ -0,0 +1,181 @@
1
+ // Per-widget dispatcher: maps a spec.kind to its built-in renderer (or a
2
+ // federated <Slot> for kind:"custom"), wrapped in an error boundary so a single
3
+ // broken widget renders its own error card instead of tumbling the grid.
4
+
5
+ import * as React from 'react'
6
+ import { Slot } from '../slot'
7
+ import type { DashboardWidgetSpec, WidgetData, WidgetSize } from '../dashboard-types'
8
+ import { WidgetCard, WidgetError } from './widget-card'
9
+ import {
10
+ StatWidget,
11
+ BarWidget,
12
+ LineWidget,
13
+ AreaWidget,
14
+ PieWidget,
15
+ DonutWidget,
16
+ ListWidget,
17
+ ProgressWidget,
18
+ type WidgetRenderProps,
19
+ } from './renderers'
20
+
21
+ /** Maps a widget size to its column span in the 4-col grid. */
22
+ export const SIZE_SPAN: Record<WidgetSize, number> = {
23
+ sm: 1,
24
+ md: 2,
25
+ lg: 3,
26
+ full: 4,
27
+ }
28
+
29
+ /** Tailwind col-span class per size (static → scanned in this package build). */
30
+ export const SIZE_CLASS: Record<WidgetSize, string> = {
31
+ sm: 'sm:col-span-2 lg:col-span-1',
32
+ md: 'sm:col-span-2 lg:col-span-2',
33
+ lg: 'sm:col-span-2 lg:col-span-3',
34
+ full: 'sm:col-span-2 lg:col-span-4',
35
+ }
36
+
37
+ const RENDERERS: Record<
38
+ string,
39
+ (p: WidgetRenderProps) => React.ReactElement
40
+ > = {
41
+ stat: StatWidget,
42
+ bar: BarWidget,
43
+ line: LineWidget,
44
+ area: AreaWidget,
45
+ pie: PieWidget,
46
+ donut: DonutWidget,
47
+ list: ListWidget,
48
+ progress: ProgressWidget,
49
+ }
50
+
51
+ interface BoundaryProps {
52
+ spec: DashboardWidgetSpec
53
+ message: string
54
+ children: React.ReactNode
55
+ }
56
+ interface BoundaryState {
57
+ error: boolean
58
+ }
59
+
60
+ /** Isolated boundary: a throwing widget renders its own error card. */
61
+ class WidgetErrorBoundary extends React.Component<BoundaryProps, BoundaryState> {
62
+ state: BoundaryState = { error: false }
63
+ static getDerivedStateFromError(): BoundaryState {
64
+ return { error: true }
65
+ }
66
+ render() {
67
+ if (this.state.error) {
68
+ return (
69
+ <WidgetCard
70
+ data-testid={`widget-${this.props.spec.key}`}
71
+ title={this.props.spec.title}
72
+ subtitle={this.props.spec.subtitle}
73
+ icon={this.props.spec.icon}
74
+ accent={this.props.spec.accent}
75
+ >
76
+ <WidgetError message={this.props.message} />
77
+ </WidgetCard>
78
+ )
79
+ }
80
+ return this.props.children
81
+ }
82
+ }
83
+
84
+ export interface WidgetRendererProps {
85
+ spec: DashboardWidgetSpec
86
+ data?: WidgetData
87
+ locale?: string
88
+ currency?: string
89
+ /** Translated empty fallback for this widget. */
90
+ emptyText: string
91
+ /** Translated error message for this widget. */
92
+ errorText: string
93
+ }
94
+
95
+ /**
96
+ * Renders one widget. `kind:"custom"` defers to the federated slot
97
+ * (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
98
+ * combines with the declarative widgets.
99
+ */
100
+ export function WidgetRenderer({
101
+ spec,
102
+ data,
103
+ locale,
104
+ currency,
105
+ emptyText,
106
+ errorText,
107
+ }: WidgetRendererProps) {
108
+ let body: React.ReactNode
109
+ if (spec.kind === 'custom') {
110
+ body = (
111
+ <WidgetCard
112
+ data-testid={`widget-${spec.key}`}
113
+ title={spec.title}
114
+ subtitle={spec.subtitle}
115
+ icon={spec.icon}
116
+ accent={spec.accent}
117
+ >
118
+ <Slot
119
+ name={spec.slot ?? 'dashboard.widgets'}
120
+ props={{ spec, data, locale, currency }}
121
+ fallback={
122
+ <div className="flex flex-1 items-center justify-center py-6 text-xs text-muted-foreground">
123
+ {emptyText}
124
+ </div>
125
+ }
126
+ />
127
+ </WidgetCard>
128
+ )
129
+ } else {
130
+ const Renderer = RENDERERS[spec.kind]
131
+ body = Renderer ? (
132
+ <Renderer
133
+ spec={spec}
134
+ data={data}
135
+ locale={locale}
136
+ currency={currency}
137
+ emptyText={emptyText}
138
+ />
139
+ ) : (
140
+ <WidgetCard
141
+ data-testid={`widget-${spec.key}`}
142
+ title={spec.title}
143
+ subtitle={spec.subtitle}
144
+ icon={spec.icon}
145
+ accent={spec.accent}
146
+ >
147
+ <WidgetError message={errorText} />
148
+ </WidgetCard>
149
+ )
150
+ }
151
+
152
+ return (
153
+ <WidgetErrorBoundary spec={spec} message={errorText}>
154
+ {body}
155
+ </WidgetErrorBoundary>
156
+ )
157
+ }
158
+
159
+ /** Skeleton placeholder shown per widget while data loads. */
160
+ export function WidgetSkeleton({ spec }: { spec: DashboardWidgetSpec }) {
161
+ const isChart =
162
+ spec.kind !== 'stat' && spec.kind !== 'progress' && spec.kind !== 'custom'
163
+ return (
164
+ <WidgetCard
165
+ data-testid={`widget-skeleton-${spec.key}`}
166
+ title={spec.title}
167
+ subtitle={spec.subtitle}
168
+ icon={spec.icon}
169
+ accent={spec.accent}
170
+ >
171
+ {isChart ? (
172
+ <div className="h-[132px] w-full animate-pulse rounded-md bg-muted" />
173
+ ) : (
174
+ <div className="flex flex-col gap-2">
175
+ <div className="h-8 w-2/3 animate-pulse rounded bg-muted" />
176
+ <div className="h-2 w-full animate-pulse rounded bg-muted/60" />
177
+ </div>
178
+ )}
179
+ </WidgetCard>
180
+ )
181
+ }