@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe

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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. package/src/primitives/time-picker.tsx +9 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.4-develop.4178.1.aad9ddaa95",
3
+ "version": "0.6.4-develop.4210.1.d412061cfe",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -128,11 +128,15 @@
128
128
  "@dnd-kit/sortable": "^10.0.0",
129
129
  "@dnd-kit/utilities": "^3.2.2",
130
130
  "@radix-ui/react-accordion": "^1.2.4",
131
+ "@radix-ui/react-dialog": "^1.1.6",
131
132
  "@radix-ui/react-popover": "^1.1.6",
132
133
  "@radix-ui/react-radio-group": "^1.2.3",
134
+ "@radix-ui/react-scroll-area": "^1.2.0",
133
135
  "@radix-ui/react-select": "^2.1.6",
136
+ "@radix-ui/react-slider": "^1.2.0",
134
137
  "@radix-ui/react-tooltip": "^1.2.8",
135
138
  "@tanstack/react-virtual": "^3.13.25",
139
+ "cmdk": "^1.0.0",
136
140
  "date-fns": "4.3.0",
137
141
  "react-big-calendar": "^1.19.4",
138
142
  "react-day-picker": "^10.0.1",
@@ -141,13 +145,13 @@
141
145
  "remark-gfm": "^4.0.1"
142
146
  },
143
147
  "peerDependencies": {
144
- "@open-mercato/shared": "0.6.4-develop.4178.1.aad9ddaa95",
148
+ "@open-mercato/shared": "0.6.4-develop.4210.1.d412061cfe",
145
149
  "react": ">=18.0.0",
146
150
  "react-dom": ">=18.0.0",
147
151
  "react-is": ">=18.0.0"
148
152
  },
149
153
  "devDependencies": {
150
- "@open-mercato/shared": "0.6.4-develop.4178.1.aad9ddaa95",
154
+ "@open-mercato/shared": "0.6.4-develop.4210.1.d412061cfe",
151
155
  "@testing-library/dom": "^10.4.1",
152
156
  "@testing-library/jest-dom": "^6.9.1",
153
157
  "@testing-library/react": "^16.3.1",
@@ -169,5 +173,5 @@
169
173
  "url": "https://github.com/open-mercato/open-mercato",
170
174
  "directory": "packages/ui"
171
175
  },
172
- "stableVersion": "0.6.3"
176
+ "stableVersion": "0.7.0"
173
177
  }
@@ -15,6 +15,7 @@ import {
15
15
  SelectValue,
16
16
  } from '../primitives/select'
17
17
  import { CompactSelectTrigger } from '../primitives/compact-select'
18
+ import { Pagination } from '../primitives/pagination'
18
19
  import { Spinner } from '../primitives/spinner'
19
20
  import { EmptyState } from '../primitives/empty-state'
20
21
  import { TooltipProvider } from '../primitives/tooltip'
@@ -346,6 +347,17 @@ const EXPORT_LABELS: Record<DataTableExportFormat, string> = {
346
347
  const EMPTY_FILTER_DEFS: FilterDef[] = []
347
348
  const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
348
349
 
350
+ // Directional shadow utilities for sticky table cells. `border-collapse: collapse`
351
+ // blocks `box-shadow` on `<td>`/`<th>`, so we paint the shadow as a pseudo-element
352
+ // gradient on the outside edge — the side opposite to the sticky anchor:
353
+ // sticky right-0 → shadow falls to the LEFT (use `before:` + `-left-2` + `to-l`)
354
+ // sticky left-0 → shadow falls to the RIGHT (use `after:` + `-right-2` + `to-r`)
355
+ // `foreground/8` matches the `--shadow-md` token opacity (8%) and is theme-aware.
356
+ const STICKY_RIGHT_SHADOW_CLASS =
357
+ 'before:absolute before:inset-y-0 before:-left-2 before:w-2 before:bg-gradient-to-l before:from-foreground/8 before:to-transparent before:pointer-events-none'
358
+ const STICKY_LEFT_SHADOW_CLASS =
359
+ 'after:absolute after:inset-y-0 after:-right-2 after:w-2 after:bg-gradient-to-r after:from-foreground/8 after:to-transparent after:pointer-events-none'
360
+
349
361
  type BulkActionExecuteResult = {
350
362
  ok: boolean
351
363
  message?: string
@@ -1954,7 +1966,7 @@ export function DataTable<T>({
1954
1966
  title={t('ui.dataTable.pagination.cache.title', 'Cache {status}', { status: normalizedCacheStatus.toUpperCase() })}
1955
1967
  >
1956
1968
  <Circle
1957
- className={`h-3.5 w-3.5 ${normalizedCacheStatus === 'hit' ? 'text-emerald-500' : 'text-amber-500'}`}
1969
+ className={`h-3.5 w-3.5 ${normalizedCacheStatus === 'hit' ? 'text-status-success-icon' : 'text-status-warning-icon'}`}
1958
1970
  strokeWidth={3}
1959
1971
  />
1960
1972
  <span className="sr-only">{t('ui.dataTable.pagination.cache.srOnly', 'Cache {status}', { status: normalizedCacheStatus.toUpperCase() })}</span>
@@ -1967,65 +1979,36 @@ export function DataTable<T>({
1967
1979
  .filter((size): size is number => typeof size === 'number' && Number.isFinite(size) && size > 0)
1968
1980
  .map((size) => Math.max(1, Math.floor(size))),
1969
1981
  )).sort((left, right) => left - right)
1970
- : []
1971
- const pageSizeSelect = pageSizeOptions.length > 0 && pagination.onPageSizeChange ? (
1972
- <span className="inline-flex flex-none items-center gap-1.5 whitespace-nowrap">
1973
- <Select
1974
- value={String(pagination.pageSize)}
1975
- onValueChange={(value) => {
1976
- pagination.onPageSizeChange!(Number(value))
1977
- scrollTableIntoView()
1978
- }}
1979
- >
1980
- <CompactSelectTrigger
1981
- className="min-w-[4rem]"
1982
- aria-label={t('ui.dataTable.pagination.rowsPerPage', 'Rows per page')}
1983
- >
1984
- <SelectValue />
1985
- </CompactSelectTrigger>
1986
- <SelectContent>
1987
- {pageSizeOptions.map((size) => (
1988
- <SelectItem key={size} value={String(size)}>{size}</SelectItem>
1989
- ))}
1990
- </SelectContent>
1991
- </Select>
1992
- <span className="whitespace-nowrap text-muted-foreground">{t('ui.dataTable.pagination.perPage', 'per page')}</span>
1993
- </span>
1994
- ) : null
1982
+ : [10, 25, 50, 100]
1995
1983
 
1996
1984
  return (
1997
- <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-3 border-t">
1998
- <div className="text-sm text-muted-foreground flex items-center justify-center sm:justify-start gap-2 flex-wrap">
1999
- <span>
2000
- {durationLabel
1985
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-3 border-t">
1986
+ {cacheBadge ? (
1987
+ <div className="flex items-center justify-center sm:justify-start gap-2 text-sm text-muted-foreground">
1988
+ {cacheBadge}
1989
+ </div>
1990
+ ) : null}
1991
+ <Pagination
1992
+ page={page}
1993
+ pageSize={pagination.pageSize}
1994
+ total={pagination.total}
1995
+ onPageChange={(next) => { onPageChange(next); scrollTableIntoView() }}
1996
+ onPageSizeChange={pagination.onPageSizeChange ? (next) => {
1997
+ pagination.onPageSizeChange!(next)
1998
+ scrollTableIntoView()
1999
+ } : undefined}
2000
+ pageSizeOptions={pageSizeOptions}
2001
+ formatPageInfo={() =>
2002
+ durationLabel
2001
2003
  ? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
2002
2004
  : t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
2003
- }
2004
- </span>
2005
- {cacheBadge}
2006
- {pageSizeSelect}
2007
- </div>
2008
- <div className="flex items-center justify-center sm:justify-end gap-2">
2009
- <Button
2010
- variant="outline"
2011
- size="sm"
2012
- onClick={() => { onPageChange(page - 1); scrollTableIntoView() }}
2013
- disabled={page <= 1}
2014
- >
2015
- {t('ui.dataTable.pagination.previous', 'Previous')}
2016
- </Button>
2017
- <span className="text-sm whitespace-nowrap">
2018
- {t('ui.dataTable.pagination.pageInfo', 'Page {page} of {totalPages}', { page, totalPages })}
2019
- </span>
2020
- <Button
2021
- variant="outline"
2022
- size="sm"
2023
- onClick={() => { onPageChange(page + 1); scrollTableIntoView() }}
2024
- disabled={page >= totalPages}
2025
- >
2026
- {t('ui.dataTable.pagination.next', 'Next')}
2027
- </Button>
2028
- </div>
2005
+ }
2006
+ formatPageSizeLabel={(size) =>
2007
+ `${size} ${t('ui.dataTable.pagination.perPage', 'per page')}`
2008
+ }
2009
+ aria-label={t('ui.dataTable.pagination.navAriaLabel', 'Table pagination')}
2010
+ className="flex-1"
2011
+ />
2029
2012
  </div>
2030
2013
  )
2031
2014
  }, [pagination, measuredDurationMs, scrollTableIntoView, t])
@@ -2661,7 +2644,7 @@ export function DataTable<T>({
2661
2644
  const columnMeta = (header.column.columnDef as any)?.meta
2662
2645
  const priority = resolvePriority(header.column)
2663
2646
  const isFirstDataColumn = headerIndex === 0
2664
- const stickyClass = stickyFirstColumn && isFirstDataColumn ? ' sticky left-0 z-10 bg-background' : ''
2647
+ const stickyClass = stickyFirstColumn && isFirstDataColumn ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : ''
2665
2648
  const headerCellContent = header.isPlaceholder ? null : (
2666
2649
  <Button
2667
2650
  variant="ghost"
@@ -2694,7 +2677,7 @@ export function DataTable<T>({
2694
2677
  <TableHead
2695
2678
  className={cn(
2696
2679
  'w-0 text-right',
2697
- stickyActionsColumn && 'sticky right-0 z-20 bg-background',
2680
+ stickyActionsColumn && `sticky right-0 z-20 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2698
2681
  )}
2699
2682
  >
2700
2683
  {t('ui.dataTable.actionsColumn', 'Actions')}
@@ -2816,7 +2799,7 @@ export function DataTable<T>({
2816
2799
  ) : content
2817
2800
 
2818
2801
  return (
2819
- <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ' sticky left-0 z-10 bg-background' : '')}>
2802
+ <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
2820
2803
  {wrappedContent}
2821
2804
  </TableCell>
2822
2805
  )
@@ -2825,7 +2808,7 @@ export function DataTable<T>({
2825
2808
  <TableCell
2826
2809
  className={cn(
2827
2810
  'text-right whitespace-nowrap',
2828
- stickyActionsColumn && 'sticky right-0 z-10 bg-background',
2811
+ stickyActionsColumn && `sticky right-0 z-10 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2829
2812
  )}
2830
2813
  data-actions-cell
2831
2814
  >
@@ -560,7 +560,7 @@ export function AttachmentMetadataDialog({ open, onOpenChange, item, availableTa
560
560
  className={cn(
561
561
  'h-auto -mb-px rounded-none border-b-2 border-transparent px-0 py-1',
562
562
  imageTab === tab
563
- ? 'border-primary text-foreground'
563
+ ? 'border-accent-indigo text-foreground'
564
564
  : 'text-muted-foreground hover:text-foreground',
565
565
  )}
566
566
  >
@@ -0,0 +1,216 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render, fireEvent } from '@testing-library/react'
5
+
6
+ import {
7
+ ActivityFeed,
8
+ ActivityFeedItem,
9
+ ActivityFeedFileChip,
10
+ ActivityFeedComment,
11
+ ActivityFeedStatusChip,
12
+ } from '../activity-feed'
13
+
14
+ describe('ActivityFeed', () => {
15
+ it('renders an <ol> root with the data-slot marker', () => {
16
+ const { container } = render(
17
+ <ActivityFeed>
18
+ <ActivityFeedItem title="Wei Chen uploaded report" />
19
+ </ActivityFeed>,
20
+ )
21
+ const root = container.querySelector('[data-slot="activity-feed"]') as HTMLElement
22
+ expect(root).not.toBeNull()
23
+ expect(root.tagName).toBe('OL')
24
+ })
25
+
26
+ it('renders item title + timestamp as a muted suffix (no separator glyph)', () => {
27
+ const { container } = render(
28
+ <ActivityFeed>
29
+ <ActivityFeedItem title="Wei Chen uploaded report" timestamp="4 min ago" />
30
+ </ActivityFeed>,
31
+ )
32
+ const title = container.querySelector('[data-slot="activity-feed-item-title"]') as HTMLElement
33
+ expect(title).not.toBeNull()
34
+ expect(title.textContent).toContain('Wei Chen uploaded report')
35
+ const ts = container.querySelector('[data-slot="activity-feed-item-timestamp"]') as HTMLElement
36
+ expect(ts).not.toBeNull()
37
+ expect(ts.textContent).toBe('4 min ago')
38
+ // No middle-dot separator anywhere in the title row — visual gap
39
+ // comes from the wrapper's `gap-x-2` only.
40
+ expect(title.textContent).not.toContain('·')
41
+ expect(ts.className).toContain('text-muted-foreground')
42
+ })
43
+
44
+ it('omits the timestamp slot when no timestamp is provided', () => {
45
+ const { container } = render(
46
+ <ActivityFeed>
47
+ <ActivityFeedItem title="No time entry" />
48
+ </ActivityFeed>,
49
+ )
50
+ expect(container.querySelector('[data-slot="activity-feed-item-timestamp"]')).toBeNull()
51
+ })
52
+
53
+ it('renders avatar + actions slots when provided', () => {
54
+ const { container } = render(
55
+ <ActivityFeed>
56
+ <ActivityFeedItem
57
+ avatar={<span data-testid="avatar">A</span>}
58
+ title="Wei Chen uploaded report"
59
+ actions={<button data-testid="kebab">⋯</button>}
60
+ />
61
+ </ActivityFeed>,
62
+ )
63
+ expect(container.querySelector('[data-slot="activity-feed-item-avatar"]')).not.toBeNull()
64
+ expect(container.querySelector('[data-testid="avatar"]')).not.toBeNull()
65
+ expect(container.querySelector('[data-slot="activity-feed-item-actions"]')).not.toBeNull()
66
+ expect(container.querySelector('[data-testid="kebab"]')).not.toBeNull()
67
+ })
68
+
69
+ it('renders an indented content block when children are provided', () => {
70
+ const { container } = render(
71
+ <ActivityFeed>
72
+ <ActivityFeedItem title="Wei Chen uploaded report">
73
+ <ActivityFeedFileChip name="apex-report.pdf" size="4mb" />
74
+ </ActivityFeedItem>
75
+ </ActivityFeed>,
76
+ )
77
+ const content = container.querySelector('[data-slot="activity-feed-item-content"]') as HTMLElement
78
+ expect(content).not.toBeNull()
79
+ expect(content.querySelector('[data-slot="activity-feed-file-chip"]')).not.toBeNull()
80
+ })
81
+
82
+ it('omits the content block when no children are provided', () => {
83
+ const { container } = render(
84
+ <ActivityFeed>
85
+ <ActivityFeedItem title="Wei Chen uploaded report" />
86
+ </ActivityFeed>,
87
+ )
88
+ expect(container.querySelector('[data-slot="activity-feed-item-content"]')).toBeNull()
89
+ })
90
+
91
+ it('renders the FileChip download button only when `onDownload` is provided', () => {
92
+ const onDownload = jest.fn()
93
+ const { container, rerender } = render(
94
+ <ActivityFeed>
95
+ <ActivityFeedItem title="t">
96
+ <ActivityFeedFileChip name="a.pdf" size="4mb" />
97
+ </ActivityFeedItem>
98
+ </ActivityFeed>,
99
+ )
100
+ expect(container.querySelector('[data-slot="activity-feed-file-chip-download"]')).toBeNull()
101
+
102
+ rerender(
103
+ <ActivityFeed>
104
+ <ActivityFeedItem title="t">
105
+ <ActivityFeedFileChip name="a.pdf" size="4mb" onDownload={onDownload} />
106
+ </ActivityFeedItem>
107
+ </ActivityFeed>,
108
+ )
109
+ const download = container.querySelector(
110
+ '[data-slot="activity-feed-file-chip-download"]',
111
+ ) as HTMLButtonElement
112
+ expect(download).not.toBeNull()
113
+ fireEvent.click(download)
114
+ expect(onDownload).toHaveBeenCalledTimes(1)
115
+ })
116
+
117
+ it('renders the Comment Reply button only when `onReply` is provided + fires the callback', () => {
118
+ const onReply = jest.fn()
119
+ const { container, rerender } = render(
120
+ <ActivityFeed>
121
+ <ActivityFeedItem title="t">
122
+ <ActivityFeedComment>Hello?</ActivityFeedComment>
123
+ </ActivityFeedItem>
124
+ </ActivityFeed>,
125
+ )
126
+ expect(container.querySelector('[data-slot="activity-feed-comment-reply"]')).toBeNull()
127
+
128
+ rerender(
129
+ <ActivityFeed>
130
+ <ActivityFeedItem title="t">
131
+ <ActivityFeedComment onReply={onReply}>Hello?</ActivityFeedComment>
132
+ </ActivityFeedItem>
133
+ </ActivityFeed>,
134
+ )
135
+ const reply = container.querySelector('[data-slot="activity-feed-comment-reply"]') as HTMLButtonElement
136
+ expect(reply).not.toBeNull()
137
+ expect(reply.textContent).toBe('Reply')
138
+ fireEvent.click(reply)
139
+ expect(onReply).toHaveBeenCalledTimes(1)
140
+ })
141
+
142
+ it('renders the StatusChip with the correct data-status + tone class per status', () => {
143
+ const cases: Array<{
144
+ status: 'success' | 'warning' | 'info' | 'error' | 'neutral'
145
+ tone: string
146
+ }> = [
147
+ { status: 'success', tone: 'text-status-success-icon' },
148
+ { status: 'warning', tone: 'text-status-warning-icon' },
149
+ { status: 'info', tone: 'text-status-info-icon' },
150
+ { status: 'error', tone: 'text-status-error-icon' },
151
+ { status: 'neutral', tone: 'text-muted-foreground' },
152
+ ]
153
+ for (const { status, tone } of cases) {
154
+ const { container, unmount } = render(
155
+ <ActivityFeedStatusChip status={status}>12 tasks completed</ActivityFeedStatusChip>,
156
+ )
157
+ const chip = container.querySelector('[data-slot="activity-feed-status-chip"]') as HTMLElement
158
+ expect(chip).not.toBeNull()
159
+ expect(chip.getAttribute('data-status')).toBe(status)
160
+ const iconWrap = chip.querySelector(
161
+ '[data-slot="activity-feed-status-chip-icon"]',
162
+ ) as HTMLElement
163
+ expect(iconWrap.className).toContain(tone)
164
+ expect(chip.textContent).toContain('12 tasks completed')
165
+ unmount()
166
+ }
167
+ })
168
+
169
+ it('defaults the StatusChip status to "neutral" when no status prop is passed', () => {
170
+ const { container } = render(
171
+ <ActivityFeedStatusChip>Idle</ActivityFeedStatusChip>,
172
+ )
173
+ const chip = container.querySelector('[data-slot="activity-feed-status-chip"]') as HTMLElement
174
+ expect(chip.getAttribute('data-status')).toBe('neutral')
175
+ })
176
+
177
+ it('forwards className on each compound slot', () => {
178
+ const { container } = render(
179
+ <ActivityFeed className="root-custom">
180
+ <ActivityFeedItem className="item-custom" title="t">
181
+ <ActivityFeedFileChip className="file-custom" name="a.pdf" />
182
+ <ActivityFeedComment className="comment-custom">c</ActivityFeedComment>
183
+ <ActivityFeedStatusChip className="status-custom">s</ActivityFeedStatusChip>
184
+ </ActivityFeedItem>
185
+ </ActivityFeed>,
186
+ )
187
+ expect(container.querySelector('[data-slot="activity-feed"]')!.className).toContain('root-custom')
188
+ expect(container.querySelector('[data-slot="activity-feed-item"]')!.className).toContain('item-custom')
189
+ expect(container.querySelector('[data-slot="activity-feed-file-chip"]')!.className).toContain('file-custom')
190
+ expect(container.querySelector('[data-slot="activity-feed-comment"]')!.className).toContain('comment-custom')
191
+ expect(container.querySelector('[data-slot="activity-feed-status-chip"]')!.className).toContain('status-custom')
192
+ })
193
+
194
+ it('renders ReactNode title (with inline status chip mixed in) — Figma-style inline sentence', () => {
195
+ const { container } = render(
196
+ <ActivityFeed>
197
+ <ActivityFeedItem
198
+ title={
199
+ <>
200
+ Laura Perez{' '}
201
+ <span className="text-muted-foreground font-normal">requested changes</span>{' '}
202
+ <ActivityFeedStatusChip status="error">Needs revision</ActivityFeedStatusChip>
203
+ </>
204
+ }
205
+ timestamp="6 days ago"
206
+ />
207
+ </ActivityFeed>,
208
+ )
209
+ const title = container.querySelector('[data-slot="activity-feed-item-title"]') as HTMLElement
210
+ expect(title.textContent).toContain('Laura Perez')
211
+ expect(title.textContent).toContain('requested changes')
212
+ // Status chip is nested inside the title (inline pattern).
213
+ expect(title.querySelector('[data-slot="activity-feed-status-chip"]')).not.toBeNull()
214
+ expect(title.querySelector('[data-status="error"]')).not.toBeNull()
215
+ })
216
+ })
@@ -61,4 +61,135 @@ describe('Avatar', () => {
61
61
  const root = container.firstChild as HTMLElement
62
62
  expect(root.className).toContain('size-12')
63
63
  })
64
+
65
+ describe('Phase B.4 — status + ring + badge slots', () => {
66
+ it('plain Avatar (no decorations) still renders as a single element (backward compat)', () => {
67
+ const { container } = render(<Avatar label="Jan" />)
68
+ // No wrapper — root IS the avatar circle.
69
+ const root = container.firstChild as HTMLElement
70
+ expect(root.getAttribute('data-slot')).toBe('avatar')
71
+ expect(container.querySelector('[data-slot="avatar-root"]')).toBeNull()
72
+ })
73
+
74
+ it('wraps in avatar-root when status is set + renders the bottom-right dot by default', () => {
75
+ const { container } = render(<Avatar label="Jan" status="online" />)
76
+ expect(container.querySelector('[data-slot="avatar-root"]')).not.toBeNull()
77
+ const dot = container.querySelector('[data-slot="avatar-status"]') as HTMLElement
78
+ expect(dot).not.toBeNull()
79
+ expect(dot.getAttribute('data-status')).toBe('online')
80
+ expect(dot.getAttribute('data-position')).toBe('bottom-right')
81
+ expect(dot.className).toContain('bg-status-success-icon')
82
+ })
83
+
84
+ it('honors statusPosition="top-right"', () => {
85
+ const { container } = render(
86
+ <Avatar label="Jan" status="busy" statusPosition="top-right" />,
87
+ )
88
+ const dot = container.querySelector('[data-slot="avatar-status"]') as HTMLElement
89
+ expect(dot.getAttribute('data-position')).toBe('top-right')
90
+ expect(dot.className).toContain('bg-status-error-icon')
91
+ })
92
+
93
+ it('renders all 8 status tones with matching bg-* classes', () => {
94
+ const cases: Array<{ status: 'online' | 'offline' | 'busy' | 'away' | 'success' | 'warning' | 'error' | 'info'; cls: string }> = [
95
+ { status: 'online', cls: 'bg-status-success-icon' },
96
+ { status: 'offline', cls: 'bg-muted-foreground' },
97
+ { status: 'busy', cls: 'bg-status-error-icon' },
98
+ { status: 'away', cls: 'bg-status-warning-icon' },
99
+ { status: 'success', cls: 'bg-status-success-icon' },
100
+ { status: 'warning', cls: 'bg-status-warning-icon' },
101
+ { status: 'error', cls: 'bg-status-error-icon' },
102
+ { status: 'info', cls: 'bg-status-info-icon' },
103
+ ]
104
+ for (const { status, cls } of cases) {
105
+ const { container, unmount } = render(<Avatar label="x" status={status} />)
106
+ const dot = container.querySelector('[data-slot="avatar-status"]') as HTMLElement
107
+ expect(dot.className).toContain(cls)
108
+ unmount()
109
+ }
110
+ })
111
+
112
+ it('scales status-dot size with avatar size', () => {
113
+ const sizes: Array<{ size: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; cls: string }> = [
114
+ { size: 'xs', cls: 'size-1.5' },
115
+ { size: 'sm', cls: 'size-2' },
116
+ { size: 'md', cls: 'size-2.5' },
117
+ { size: 'lg', cls: 'size-3' },
118
+ { size: 'xl', cls: 'size-4' },
119
+ ]
120
+ for (const { size, cls } of sizes) {
121
+ const { container, unmount } = render(
122
+ <Avatar label="x" status="online" size={size} />,
123
+ )
124
+ const dot = container.querySelector('[data-slot="avatar-status"]') as HTMLElement
125
+ expect(dot.className).toContain(cls)
126
+ unmount()
127
+ }
128
+ })
129
+
130
+ it('renders the badge slot (replaces top-right status when both are set with statusPosition=top-right)', () => {
131
+ const { container } = render(
132
+ <Avatar
133
+ label="x"
134
+ status="online"
135
+ statusPosition="top-right"
136
+ badge={<span data-testid="check">✓</span>}
137
+ />,
138
+ )
139
+ const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
140
+ expect(badge).not.toBeNull()
141
+ expect(badge.querySelector('[data-testid="check"]')).not.toBeNull()
142
+ // No top-right status dot when badge overrides.
143
+ expect(
144
+ container.querySelector('[data-slot="avatar-status"][data-position="top-right"]'),
145
+ ).toBeNull()
146
+ })
147
+
148
+ it('badge + bottom-right status coexist (badge takes top-right, dot takes bottom-right)', () => {
149
+ const { container } = render(
150
+ <Avatar
151
+ label="x"
152
+ status="online"
153
+ statusPosition="bottom-right"
154
+ badge={<span data-testid="check">✓</span>}
155
+ />,
156
+ )
157
+ const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
158
+ expect(badge).toBeNull()
159
+ // When badge is set, the bottom dot is suppressed (badge replaces all overlays
160
+ // unless statusPosition explicitly conflicts).
161
+ })
162
+
163
+ it('renders the outer ring when ring=true (accent)', () => {
164
+ const { container } = render(<Avatar label="x" ring />)
165
+ const root = container.querySelector('[data-slot="avatar-root"]') as HTMLElement
166
+ expect(root).not.toBeNull()
167
+ expect(root.className).toContain('ring-accent-indigo')
168
+ })
169
+
170
+ it('renders the outer ring with all 5 tone variants', () => {
171
+ const tones: Array<{ tone: 'accent' | 'success' | 'warning' | 'error' | 'muted'; cls: string }> = [
172
+ { tone: 'accent', cls: 'ring-accent-indigo' },
173
+ { tone: 'success', cls: 'ring-status-success-icon' },
174
+ { tone: 'warning', cls: 'ring-status-warning-icon' },
175
+ { tone: 'error', cls: 'ring-status-error-icon' },
176
+ { tone: 'muted', cls: 'ring-input' },
177
+ ]
178
+ for (const { tone, cls } of tones) {
179
+ const { container, unmount } = render(<Avatar label="x" ring={tone} />)
180
+ const root = container.querySelector('[data-slot="avatar-root"]') as HTMLElement
181
+ expect(root.className).toContain(cls)
182
+ unmount()
183
+ }
184
+ })
185
+
186
+ it('badgeClassName overrides the default badge wrapper class', () => {
187
+ const { container } = render(
188
+ <Avatar label="x" badge={<span>X</span>} badgeClassName="size-8 bg-status-error-icon" />,
189
+ )
190
+ const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
191
+ expect(badge.className).toContain('size-8')
192
+ expect(badge.className).toContain('bg-status-error-icon')
193
+ })
194
+ })
64
195
  })