@open-mercato/ui 0.6.5-develop.5382.1.f542de69af → 0.6.6-develop.5412.1.e2a52b14f0

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.
@@ -264,6 +264,8 @@ export type DataTableProps<T> = {
264
264
  extensionTableId?: string
265
265
  stickyFirstColumn?: boolean
266
266
  stickyActionsColumn?: boolean
267
+ /** Horizontal alignment of the row-actions (kebab) column header + cell. Defaults to 'right'. */
268
+ actionsColumnAlign?: 'right' | 'center'
267
269
  virtualized?: boolean
268
270
  virtualizedMaxHeight?: number | string
269
271
  virtualizedOverscan?: number
@@ -1000,6 +1002,7 @@ export function DataTable<T>({
1000
1002
  extensionTableId: extensionTableIdProp,
1001
1003
  stickyFirstColumn = false,
1002
1004
  stickyActionsColumn = false,
1005
+ actionsColumnAlign = 'right',
1003
1006
  virtualized = false,
1004
1007
  virtualizedMaxHeight,
1005
1008
  virtualizedOverscan = 10,
@@ -2710,7 +2713,7 @@ export function DataTable<T>({
2710
2713
  <Button
2711
2714
  variant="ghost"
2712
2715
  type="button"
2713
- className={`h-auto p-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
2716
+ className={`h-auto p-0 has-[>svg]:px-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
2714
2717
  onClick={() => sortable && header.column.toggleSorting?.(header.column.getIsSorted() === 'asc')}
2715
2718
  >
2716
2719
  {flexRender(header.column.columnDef.header, header.getContext())}
@@ -2737,7 +2740,7 @@ export function DataTable<T>({
2737
2740
  {rowActions || injectedRowActions.length > 0 ? (
2738
2741
  <TableHead
2739
2742
  className={cn(
2740
- 'w-0 text-right',
2743
+ actionsColumnAlign === 'center' ? 'w-0 text-center' : 'w-0 text-right',
2741
2744
  stickyActionsColumn && `sticky right-0 z-20 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2742
2745
  )}
2743
2746
  >
@@ -2868,7 +2871,7 @@ export function DataTable<T>({
2868
2871
  {rowActions || injectedRowActions.length > 0 ? (
2869
2872
  <TableCell
2870
2873
  className={cn(
2871
- 'text-right whitespace-nowrap',
2874
+ actionsColumnAlign === 'center' ? 'text-center whitespace-nowrap' : 'text-right whitespace-nowrap',
2872
2875
  stickyActionsColumn && `sticky right-0 z-10 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2873
2876
  )}
2874
2877
  data-actions-cell
@@ -20,6 +20,8 @@ export type KpiCardProps = {
20
20
  suffix?: string
21
21
  className?: string
22
22
  headerAction?: React.ReactNode
23
+ footer?: React.ReactNode
24
+ titleClassName?: string
23
25
  }
24
26
 
25
27
  function defaultFormatValue(value: number): string {
@@ -32,17 +34,21 @@ function defaultFormatValue(value: number): string {
32
34
  return value.toLocaleString(undefined, { maximumFractionDigits: 2 })
33
35
  }
34
36
 
35
- function formatPercentageChange(value: number): string {
36
- const formatted = Math.abs(value).toFixed(1)
37
- return `${formatted}%`
37
+ function formatPercentageChange(value: number, unit: string = '%'): string {
38
+ const abs = Math.abs(value)
39
+ const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1)
40
+ return `${formatted}${unit}`
38
41
  }
39
42
 
40
43
  type BadgeDeltaProps = {
41
44
  direction: 'up' | 'down' | 'unchanged'
42
45
  value: number
46
+ unit?: string
47
+ className?: string
48
+ title?: string
43
49
  }
44
50
 
45
- function BadgeDelta({ direction, value }: BadgeDeltaProps) {
51
+ function BadgeDelta({ direction, value, unit = '%', className = '', title = 'Compared to previous period' }: BadgeDeltaProps) {
46
52
  const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'
47
53
 
48
54
  const directionClasses = {
@@ -71,15 +77,17 @@ function BadgeDelta({ direction, value }: BadgeDeltaProps) {
71
77
 
72
78
  return (
73
79
  <span
74
- className={`${baseClasses} ${directionClasses[direction]}`}
75
- title="Compared to previous period"
80
+ className={`${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ''}`}
81
+ title={title}
76
82
  >
77
83
  {icons[direction]}
78
- {formatPercentageChange(value)}
84
+ {formatPercentageChange(value, unit)}
79
85
  </span>
80
86
  )
81
87
  }
82
88
 
89
+ export const DeltaBadge = BadgeDelta
90
+
83
91
  export function KpiCard({
84
92
  title,
85
93
  value,
@@ -92,13 +100,15 @@ export function KpiCard({
92
100
  suffix = '',
93
101
  className = '',
94
102
  headerAction,
103
+ footer,
104
+ titleClassName,
95
105
  }: KpiCardProps) {
96
106
  const hasWrapper = !!title
97
107
  const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className
98
108
 
99
109
  const headerRow = (title || headerAction) ? (
100
110
  <div className="flex items-center justify-between gap-2 mb-2">
101
- {title && <p className="text-sm font-medium text-muted-foreground">{title}</p>}
111
+ {title && <p className={titleClassName ?? 'text-sm font-medium text-muted-foreground'}>{title}</p>}
102
112
  {headerAction}
103
113
  </div>
104
114
  ) : null
@@ -136,10 +146,9 @@ export function KpiCard({
136
146
  <div className={wrapperClass}>
137
147
  {headerRow}
138
148
  <div className="flex items-baseline gap-3">
139
- <p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
140
- {prefix}
141
- {formatValue(value)}
142
- {suffix}
149
+ <p className="flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
150
+ <span>{prefix}{formatValue(value)}</span>
151
+ {suffix ? <span className="text-sm font-medium text-muted-foreground">{suffix}</span> : null}
143
152
  </p>
144
153
  {trend && (
145
154
  <BadgeDelta direction={trend.direction} value={trend.value} />
@@ -148,6 +157,7 @@ export function KpiCard({
148
157
  {trend && comparisonLabel && (
149
158
  <p className="mt-1 text-xs text-muted-foreground">{comparisonLabel}</p>
150
159
  )}
160
+ {footer != null && <div className="mt-3">{footer}</div>}
151
161
  </div>
152
162
  )
153
163
  }
@@ -0,0 +1,87 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ export type SparklineProps = {
6
+ values: number[]
7
+ ariaLabel: string
8
+ className?: string
9
+ width?: number
10
+ height?: number
11
+ }
12
+
13
+ function buildPoints(values: number[], width: number, height: number): Array<{ x: number; y: number }> {
14
+ const count = values.length
15
+ const padding = 2
16
+ const usableHeight = Math.max(height - padding * 2, 0)
17
+
18
+ if (count === 1) {
19
+ return [{ x: width / 2, y: height / 2 }]
20
+ }
21
+
22
+ let min = values[0]
23
+ let max = values[0]
24
+ for (const value of values) {
25
+ if (value < min) min = value
26
+ if (value > max) max = value
27
+ }
28
+ const range = max - min
29
+
30
+ const stepX = count > 1 ? width / (count - 1) : 0
31
+
32
+ return values.map((value, index) => {
33
+ const x = stepX * index
34
+ const ratio = range === 0 ? 0.5 : (value - min) / range
35
+ const y = padding + (1 - ratio) * usableHeight
36
+ return { x, y }
37
+ })
38
+ }
39
+
40
+ function toPath(points: Array<{ x: number; y: number }>): string {
41
+ return points
42
+ .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
43
+ .join(' ')
44
+ }
45
+
46
+ export function Sparkline({
47
+ values,
48
+ ariaLabel,
49
+ className = '',
50
+ width = 96,
51
+ height = 28,
52
+ }: SparklineProps) {
53
+ if (!values || values.length === 0) {
54
+ return null
55
+ }
56
+
57
+ const points = buildPoints(values, width, height)
58
+ const linePath = toPath(points)
59
+
60
+ const firstPoint = points[0]
61
+ const lastPoint = points[points.length - 1]
62
+ const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`
63
+
64
+ return (
65
+ <svg
66
+ role="img"
67
+ aria-label={ariaLabel}
68
+ width={width}
69
+ height={height}
70
+ viewBox={`0 0 ${width} ${height}`}
71
+ preserveAspectRatio="none"
72
+ className={className}
73
+ >
74
+ <path d={areaPath} fill="currentColor" fillOpacity={0.12} stroke="none" />
75
+ <path
76
+ d={linePath}
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth={1.5}
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ />
83
+ </svg>
84
+ )
85
+ }
86
+
87
+ export default Sparkline
@@ -1,4 +1,5 @@
1
- export { KpiCard, type KpiCardProps, type KpiTrend } from './KpiCard'
1
+ export { KpiCard, DeltaBadge, type KpiCardProps, type KpiTrend } from './KpiCard'
2
+ export { Sparkline, type SparklineProps } from './Sparkline'
2
3
  export { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'
3
4
  export { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'
4
5
  export { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'
@@ -10,7 +10,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
10
10
  import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
11
11
  import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
12
12
  import { cn } from '@open-mercato/shared/lib/utils'
13
- import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
13
+ import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
14
14
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
16
  import { InjectionSpot } from '../injection/InjectionSpot'
@@ -366,7 +366,6 @@ export function DashboardScreen() {
366
366
  if (!hasRegisteredWidgets && layout.length === 0) {
367
367
  return (
368
368
  <Alert variant="info">
369
- <Info className="h-4 w-4" aria-hidden />
370
369
  <AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
371
370
  <AlertDescription>
372
371
  {t(
@@ -133,6 +133,22 @@ describe('Alert primitive', () => {
133
133
  render(<Alert status="information" style="light" icon={<Custom />}>Info</Alert>)
134
134
  expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
135
135
  })
136
+
137
+ it('renders exactly one leading icon by default (#2759)', () => {
138
+ const { container } = render(<Alert status="information">Info</Alert>)
139
+ expect(container.querySelectorAll('[data-slot="alert-icon-badge"]')).toHaveLength(1)
140
+ expect(container.querySelectorAll('[data-slot="alert-icon-badge"] > svg')).toHaveLength(1)
141
+ })
142
+
143
+ it('icon prop REPLACES the default status icon instead of adding a second one (#2759)', () => {
144
+ const Custom = () => <svg data-testid="custom-icon" aria-hidden="true" />
145
+ const { container } = render(
146
+ <Alert status="information" icon={<Custom />}>Info</Alert>,
147
+ )
148
+ expect(container.querySelectorAll('[data-slot="alert-icon-badge"]')).toHaveLength(1)
149
+ expect(container.querySelectorAll('[data-slot="alert-icon-badge"] > svg')).toHaveLength(1)
150
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
151
+ })
136
152
  })
137
153
 
138
154
  describe('Action slot + dismiss', () => {
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react'
2
2
  import { render, screen } from '@testing-library/react'
3
- import { Avatar } from '../avatar'
3
+ import { Avatar, AvatarStack } from '../avatar'
4
4
 
5
5
  describe('Avatar', () => {
6
6
  it('renders two-character initials for multi-word labels', () => {
@@ -193,3 +193,38 @@ describe('Avatar', () => {
193
193
  })
194
194
  })
195
195
  })
196
+
197
+ describe('AvatarStack', () => {
198
+ it('derives the +N overflow from children beyond max', () => {
199
+ render(
200
+ <AvatarStack max={2}>
201
+ <Avatar label="Alice Adams" />
202
+ <Avatar label="Bob Brown" />
203
+ <Avatar label="Carol Clark" />
204
+ </AvatarStack>,
205
+ )
206
+ expect(screen.getByText('+1')).toBeInTheDocument()
207
+ })
208
+
209
+ it('adds overflowCount (server-truncated items) to the children-derived overflow', () => {
210
+ // 5 children, max 4 → 1 hidden locally; overflowCount=7 server-hidden → "+8".
211
+ render(
212
+ <AvatarStack max={4} overflowCount={7}>
213
+ {Array.from({ length: 5 }, (_, index) => (
214
+ <Avatar key={index} label={`Owner ${index}`} />
215
+ ))}
216
+ </AvatarStack>,
217
+ )
218
+ expect(screen.getByText('+8')).toBeInTheDocument()
219
+ })
220
+
221
+ it('shows no overflow badge when children fit and overflowCount is 0', () => {
222
+ render(
223
+ <AvatarStack max={4}>
224
+ <Avatar label="Alice Adams" />
225
+ <Avatar label="Bob Brown" />
226
+ </AvatarStack>,
227
+ )
228
+ expect(screen.queryByText(/^\+\d/)).not.toBeInTheDocument()
229
+ })
230
+ })
@@ -279,12 +279,18 @@ export type AvatarStackProps = {
279
279
  max?: number
280
280
  size?: VariantProps<typeof avatarVariants>['size']
281
281
  className?: string
282
+ /**
283
+ * Additional hidden items not represented by `children` — e.g. a server-truncated list that
284
+ * already returned only the top N owners. Added to the children-derived overflow so the
285
+ * "+N" badge reflects the true number of omitted items, not just the ones dropped locally.
286
+ */
287
+ overflowCount?: number
282
288
  }
283
289
 
284
- export function AvatarStack({ children, max = 4, size = 'md', className }: AvatarStackProps) {
290
+ export function AvatarStack({ children, max = 4, size = 'md', className, overflowCount = 0 }: AvatarStackProps) {
285
291
  const items = React.Children.toArray(children)
286
292
  const visible = items.slice(0, max)
287
- const overflow = items.length - max
293
+ const overflow = Math.max(0, items.length - max) + Math.max(0, overflowCount)
288
294
 
289
295
  return (
290
296
  <div