@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
@@ -0,0 +1,199 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCaption,
10
+ TableCell,
11
+ TableFooter,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from '../table'
16
+
17
+ describe('Table primitive (Phase B.6 polish)', () => {
18
+ it('renders a <table> with data-slot attributes for each slot', () => {
19
+ const { container } = render(
20
+ <Table>
21
+ <TableCaption>Members directory</TableCaption>
22
+ <TableHeader>
23
+ <TableRow>
24
+ <TableHead>Name</TableHead>
25
+ </TableRow>
26
+ </TableHeader>
27
+ <TableBody>
28
+ <TableRow>
29
+ <TableCell>Jan</TableCell>
30
+ </TableRow>
31
+ </TableBody>
32
+ <TableFooter>
33
+ <TableRow>
34
+ <TableCell>1 member</TableCell>
35
+ </TableRow>
36
+ </TableFooter>
37
+ </Table>,
38
+ )
39
+ expect(container.querySelector('[data-slot="table"]')).not.toBeNull()
40
+ expect(container.querySelector('[data-slot="table-caption"]')).not.toBeNull()
41
+ expect(container.querySelector('[data-slot="table-header"]')).not.toBeNull()
42
+ expect(container.querySelector('[data-slot="table-body"]')).not.toBeNull()
43
+ expect(container.querySelector('[data-slot="table-footer"]')).not.toBeNull()
44
+ expect(container.querySelectorAll('[data-slot="table-row"]').length).toBe(3)
45
+ expect(container.querySelector('[data-slot="table-head"]')).not.toBeNull()
46
+ expect(container.querySelector('[data-slot="table-cell"]')).not.toBeNull()
47
+ })
48
+
49
+ it('TableHeader gets bg-muted/40 strip per Figma', () => {
50
+ const { container } = render(
51
+ <Table>
52
+ <TableHeader>
53
+ <TableRow>
54
+ <TableHead>Col</TableHead>
55
+ </TableRow>
56
+ </TableHeader>
57
+ </Table>,
58
+ )
59
+ const head = container.querySelector('[data-slot="table-header"]') as HTMLElement
60
+ expect(head.className).toContain('bg-muted/40')
61
+ })
62
+
63
+ it('TableFooter gets bordered top + bg-muted/40 strip', () => {
64
+ const { container } = render(
65
+ <Table>
66
+ <TableFooter>
67
+ <TableRow>
68
+ <TableCell>Total</TableCell>
69
+ </TableRow>
70
+ </TableFooter>
71
+ </Table>,
72
+ )
73
+ const footer = container.querySelector('[data-slot="table-footer"]') as HTMLElement
74
+ expect(footer.className).toContain('border-t')
75
+ expect(footer.className).toContain('bg-muted/40')
76
+ expect(footer.className).toContain('font-medium')
77
+ })
78
+
79
+ it('TableRow gets hover:bg-muted/30 by default + border-b last:border-b-0', () => {
80
+ const { container } = render(
81
+ <Table>
82
+ <TableBody>
83
+ <TableRow>
84
+ <TableCell>x</TableCell>
85
+ </TableRow>
86
+ </TableBody>
87
+ </Table>,
88
+ )
89
+ const row = container.querySelector('tbody [data-slot="table-row"]') as HTMLElement
90
+ expect(row.className).toContain('hover:bg-muted/30')
91
+ expect(row.className).toContain('border-b')
92
+ expect(row.className).toContain('last:border-b-0')
93
+ expect(row.className).toContain('transition-colors')
94
+ })
95
+
96
+ it('Striped variant adds even-row bg-muted/20 via context', () => {
97
+ const { container } = render(
98
+ <Table variant="striped">
99
+ <TableBody>
100
+ <TableRow>
101
+ <TableCell>1</TableCell>
102
+ </TableRow>
103
+ <TableRow>
104
+ <TableCell>2</TableCell>
105
+ </TableRow>
106
+ </TableBody>
107
+ </Table>,
108
+ )
109
+ const root = container.querySelector('[data-slot="table"]') as HTMLElement
110
+ expect(root.getAttribute('data-variant')).toBe('striped')
111
+ const rows = Array.from(
112
+ container.querySelectorAll('tbody [data-slot="table-row"]'),
113
+ ) as HTMLElement[]
114
+ expect(rows.length).toBe(2)
115
+ for (const row of rows) {
116
+ expect(row.className).toContain('even:bg-muted/20')
117
+ }
118
+ })
119
+
120
+ it('Default variant data-variant attribute reads "default"', () => {
121
+ const { container } = render(
122
+ <Table>
123
+ <TableBody>
124
+ <TableRow>
125
+ <TableCell>x</TableCell>
126
+ </TableRow>
127
+ </TableBody>
128
+ </Table>,
129
+ )
130
+ const root = container.querySelector('[data-slot="table"]') as HTMLElement
131
+ expect(root.getAttribute('data-variant')).toBe('default')
132
+ const row = container.querySelector('tbody [data-slot="table-row"]') as HTMLElement
133
+ expect(row.className).not.toContain('even:bg-muted/20')
134
+ })
135
+
136
+ it('TableHead keeps text-muted-foreground + font-medium + whitespace-nowrap', () => {
137
+ const { container } = render(
138
+ <Table>
139
+ <TableHeader>
140
+ <TableRow>
141
+ <TableHead>Header</TableHead>
142
+ </TableRow>
143
+ </TableHeader>
144
+ </Table>,
145
+ )
146
+ const head = container.querySelector('[data-slot="table-head"]') as HTMLElement
147
+ expect(head.className).toContain('text-muted-foreground')
148
+ expect(head.className).toContain('font-medium')
149
+ expect(head.className).toContain('whitespace-nowrap')
150
+ expect(head.className).toContain('px-4')
151
+ expect(head.className).toContain('py-2')
152
+ })
153
+
154
+ it('TableCell keeps the original px-4 py-2 padding (backward compat)', () => {
155
+ const { container } = render(
156
+ <Table>
157
+ <TableBody>
158
+ <TableRow>
159
+ <TableCell>cell</TableCell>
160
+ </TableRow>
161
+ </TableBody>
162
+ </Table>,
163
+ )
164
+ const cell = container.querySelector('[data-slot="table-cell"]') as HTMLElement
165
+ expect(cell.className).toContain('px-4')
166
+ expect(cell.className).toContain('py-2')
167
+ })
168
+
169
+ it('forwards className on every slot', () => {
170
+ const { container } = render(
171
+ <Table className="t-custom">
172
+ <TableCaption className="cap-custom">cap</TableCaption>
173
+ <TableHeader className="head-custom">
174
+ <TableRow className="row-custom">
175
+ <TableHead className="th-custom">x</TableHead>
176
+ </TableRow>
177
+ </TableHeader>
178
+ <TableBody className="body-custom">
179
+ <TableRow>
180
+ <TableCell className="cell-custom">x</TableCell>
181
+ </TableRow>
182
+ </TableBody>
183
+ <TableFooter className="foot-custom">
184
+ <TableRow>
185
+ <TableCell>x</TableCell>
186
+ </TableRow>
187
+ </TableFooter>
188
+ </Table>,
189
+ )
190
+ expect(container.querySelector('[data-slot="table"]')!.className).toContain('t-custom')
191
+ expect(container.querySelector('[data-slot="table-caption"]')!.className).toContain('cap-custom')
192
+ expect(container.querySelector('[data-slot="table-header"]')!.className).toContain('head-custom')
193
+ expect(container.querySelector('[data-slot="table-row"]')!.className).toContain('row-custom')
194
+ expect(container.querySelector('[data-slot="table-head"]')!.className).toContain('th-custom')
195
+ expect(container.querySelector('[data-slot="table-body"]')!.className).toContain('body-custom')
196
+ expect(container.querySelector('[data-slot="table-cell"]')!.className).toContain('cell-custom')
197
+ expect(container.querySelector('[data-slot="table-footer"]')!.className).toContain('foot-custom')
198
+ })
199
+ })
@@ -70,3 +70,142 @@ describe('Tabs context provider', () => {
70
70
  expect(after).not.toBe(before)
71
71
  })
72
72
  })
73
+
74
+ describe('Tabs Phase B.5 — variant + orientation + leading + count', () => {
75
+ it('defaults to variant="pill" + orientation="horizontal" (backward compat)', () => {
76
+ const { container } = render(
77
+ <Tabs defaultValue="a">
78
+ <TabsList>
79
+ <TabsTrigger value="a">A</TabsTrigger>
80
+ </TabsList>
81
+ <TabsContent value="a">Content</TabsContent>
82
+ </Tabs>,
83
+ )
84
+ const root = container.querySelector('[data-slot="tabs"]') as HTMLElement
85
+ expect(root.getAttribute('data-variant')).toBe('pill')
86
+ expect(root.getAttribute('data-orientation')).toBe('horizontal')
87
+ const list = container.querySelector('[data-slot="tabs-list"]') as HTMLElement
88
+ expect(list.className).toContain('bg-muted')
89
+ expect(list.className).toContain('rounded-lg')
90
+ expect(list.getAttribute('aria-orientation')).toBe('horizontal')
91
+ })
92
+
93
+ it('switches to underline strip when variant="underline"', () => {
94
+ const { container } = render(
95
+ <Tabs defaultValue="a" variant="underline">
96
+ <TabsList>
97
+ <TabsTrigger value="a">A</TabsTrigger>
98
+ <TabsTrigger value="b">B</TabsTrigger>
99
+ </TabsList>
100
+ </Tabs>,
101
+ )
102
+ const list = container.querySelector('[data-slot="tabs-list"]') as HTMLElement
103
+ expect(list.className).toContain('border-b')
104
+ expect(list.className).not.toContain('bg-muted')
105
+ const active = container.querySelector('[data-state="active"]') as HTMLElement
106
+ expect(active.getAttribute('data-variant')).toBe('underline')
107
+ expect(active.className).toContain('border-accent-indigo')
108
+ expect(active.className).toContain('font-semibold')
109
+ })
110
+
111
+ it('vertical orientation renders list as a column', () => {
112
+ const { container } = render(
113
+ <Tabs defaultValue="a" orientation="vertical">
114
+ <TabsList>
115
+ <TabsTrigger value="a">A</TabsTrigger>
116
+ </TabsList>
117
+ </Tabs>,
118
+ )
119
+ const root = container.querySelector('[data-slot="tabs"]') as HTMLElement
120
+ expect(root.getAttribute('data-orientation')).toBe('vertical')
121
+ expect(root.className).toContain('flex')
122
+ expect(root.className).toContain('gap-4')
123
+ const list = container.querySelector('[data-slot="tabs-list"]') as HTMLElement
124
+ expect(list.className).toContain('flex-col')
125
+ expect(list.getAttribute('aria-orientation')).toBe('vertical')
126
+ })
127
+
128
+ it('vertical underline switches to right-side border + bg-muted on active item', () => {
129
+ const { container } = render(
130
+ <Tabs defaultValue="a" variant="underline" orientation="vertical">
131
+ <TabsList>
132
+ <TabsTrigger value="a">A</TabsTrigger>
133
+ <TabsTrigger value="b">B</TabsTrigger>
134
+ </TabsList>
135
+ </Tabs>,
136
+ )
137
+ const list = container.querySelector('[data-slot="tabs-list"]') as HTMLElement
138
+ expect(list.className).toContain('border-r')
139
+ const active = container.querySelector('[data-state="active"]') as HTMLElement
140
+ expect(active.className).toContain('bg-muted/40')
141
+ expect(active.className).toContain('text-foreground')
142
+ })
143
+
144
+ it('renders the leading icon slot when provided (both variants)', () => {
145
+ const Icon = () => <svg data-testid="lead" />
146
+ const { container, rerender } = render(
147
+ <Tabs defaultValue="a">
148
+ <TabsList>
149
+ <TabsTrigger value="a" leading={<Icon />}>A</TabsTrigger>
150
+ </TabsList>
151
+ </Tabs>,
152
+ )
153
+ expect(container.querySelector('[data-slot="tabs-trigger-leading"]')).not.toBeNull()
154
+ expect(container.querySelector('[data-testid="lead"]')).not.toBeNull()
155
+
156
+ rerender(
157
+ <Tabs defaultValue="a" variant="underline">
158
+ <TabsList>
159
+ <TabsTrigger value="a" leading={<Icon />}>A</TabsTrigger>
160
+ </TabsList>
161
+ </Tabs>,
162
+ )
163
+ expect(container.querySelector('[data-slot="tabs-trigger-leading"]')).not.toBeNull()
164
+ })
165
+
166
+ it('renders the count slot on selected trigger with accent-indigo tone', () => {
167
+ const { container } = render(
168
+ <Tabs defaultValue="a">
169
+ <TabsList>
170
+ <TabsTrigger value="a" count={5}>Active</TabsTrigger>
171
+ <TabsTrigger value="b" count={3}>Inactive</TabsTrigger>
172
+ </TabsList>
173
+ </Tabs>,
174
+ )
175
+ const counts = Array.from(
176
+ container.querySelectorAll('[data-slot="tabs-trigger-count"]'),
177
+ ) as HTMLElement[]
178
+ expect(counts.length).toBe(2)
179
+ // The first (selected) trigger's count uses accent-indigo tone.
180
+ expect(counts[0].className).toContain('bg-accent-indigo/10')
181
+ expect(counts[0].className).toContain('text-accent-indigo')
182
+ // The second (inactive) trigger's count uses the muted variant.
183
+ expect(counts[1].className).toContain('text-muted-foreground')
184
+ expect(counts[0].textContent).toBe('5')
185
+ expect(counts[1].textContent).toBe('3')
186
+ })
187
+
188
+ it('count=0 still renders (typeof check is `!== undefined`)', () => {
189
+ const { container } = render(
190
+ <Tabs defaultValue="a">
191
+ <TabsList>
192
+ <TabsTrigger value="a" count={0}>Empty</TabsTrigger>
193
+ </TabsList>
194
+ </Tabs>,
195
+ )
196
+ const count = container.querySelector('[data-slot="tabs-trigger-count"]') as HTMLElement
197
+ expect(count).not.toBeNull()
198
+ expect(count.textContent).toBe('0')
199
+ })
200
+
201
+ it('omits the count slot entirely when prop is undefined', () => {
202
+ const { container } = render(
203
+ <Tabs defaultValue="a">
204
+ <TabsList>
205
+ <TabsTrigger value="a">No count</TabsTrigger>
206
+ </TabsList>
207
+ </Tabs>,
208
+ )
209
+ expect(container.querySelector('[data-slot="tabs-trigger-count"]')).toBeNull()
210
+ })
211
+ })
@@ -0,0 +1,326 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { AlertTriangle, CheckCircle2, Clock, Download, MessageCircle, Paperclip, XCircle } from 'lucide-react'
5
+
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+
8
+ /**
9
+ * Chronological activity / audit timeline primitive — list of actor
10
+ * actions (who did what, when) with optional inline objects and
11
+ * indented attachment / comment / status rows beneath each entry.
12
+ *
13
+ * Compound API:
14
+ * <ActivityFeed> — root list container.
15
+ * <ActivityFeedItem> — single entry. Slots:
16
+ * `avatar` (left, size-7 ~28px),
17
+ * `title` (actor + verb + inline
18
+ * object — ReactNode so
19
+ * consumers can mix bold
20
+ * actor names with muted
21
+ * verbs and inline chips),
22
+ * `timestamp` (rendered as
23
+ * muted suffix text —
24
+ * no separator glyph),
25
+ * `actions` (right slot — typically
26
+ * a `MoreHorizontal`
27
+ * IconButton),
28
+ * `children` (indented attachment
29
+ * / comment / status row
30
+ * below the title).
31
+ * <ActivityFeedFileChip> — paperclip + filename + size +
32
+ * optional download button. Used
33
+ * inline in the title or in the
34
+ * indented attachment row below.
35
+ * <ActivityFeedComment> — comment card (avatar + body +
36
+ * Reply link). Indented attachment
37
+ * style — render as a child of
38
+ * `ActivityFeedItem`.
39
+ * <ActivityFeedStatusChip> — semantic status pill (success /
40
+ * warning / info / error /
41
+ * neutral). Used inline in the
42
+ * title for short statuses
43
+ * ("Pending review", "Approved")
44
+ * or stacked in the indented row.
45
+ *
46
+ * Figma source: DS Open Mercato `Activity Feed` page (`164611:26451`)
47
+ * — `Activity Feed [1.1]` (`166035:46833`, 5 entry variants: plain,
48
+ * file attachments, comment, avatar stack, task-status pills);
49
+ * `Activity Feed File Items [1.1]` (`165967:4028`); `Activity Feed
50
+ * Comment Items [1.1]` (`166017:612`); `Activity Feed Task Status
51
+ * Items [1.1]` (`166035:47290`, 4 statuses: success / warning /
52
+ * info / error). Assembled example: `166707:8700`.
53
+ *
54
+ * ```tsx
55
+ * <ActivityFeed>
56
+ * <ActivityFeedItem
57
+ * avatar={<Avatar label="Wei Chen" size="sm" />}
58
+ * title={<>Wei Chen <span className="text-muted-foreground font-normal">uploaded</span> <strong>Q2 financial report</strong></>}
59
+ * timestamp="4 min ago"
60
+ * actions={<IconButton variant="ghost" size="sm" aria-label="More"><MoreHorizontal /></IconButton>}
61
+ * >
62
+ * <ActivityFeedFileChip name="apex-report.pdf" size="4mb" onDownload={() => {}} />
63
+ * </ActivityFeedItem>
64
+ *
65
+ * <ActivityFeedItem
66
+ * avatar={<Avatar label="Laura Perez" size="sm" />}
67
+ * title={<>Laura Perez <span className="text-muted-foreground font-normal">requested changes</span> <ActivityFeedStatusChip status="error">Needs revision</ActivityFeedStatusChip></>}
68
+ * timestamp="6 days ago"
69
+ * >
70
+ * <ActivityFeedComment onReply={() => {}}>
71
+ * Please revise the risk metrics and review portfolio allocations.
72
+ * </ActivityFeedComment>
73
+ * </ActivityFeedItem>
74
+ * </ActivityFeed>
75
+ * ```
76
+ */
77
+
78
+ export type ActivityFeedProps = React.HTMLAttributes<HTMLOListElement>
79
+
80
+ const ActivityFeed = React.forwardRef<HTMLOListElement, ActivityFeedProps>(
81
+ ({ className, ...props }, ref) => (
82
+ <ol
83
+ ref={ref}
84
+ data-slot="activity-feed"
85
+ className={cn('flex flex-col gap-3', className)}
86
+ {...props}
87
+ />
88
+ ),
89
+ )
90
+ ActivityFeed.displayName = 'ActivityFeed'
91
+
92
+ export type ActivityFeedItemProps = Omit<React.HTMLAttributes<HTMLLIElement>, 'title'> & {
93
+ /** Left slot — typically an `Avatar` at `size="sm"` (28px). */
94
+ avatar?: React.ReactNode
95
+ /** Headline row — actor + verb + inline object. Pass a ReactNode so
96
+ * consumers can mix bold actor names with muted verbs and inline
97
+ * chips (`ActivityFeedFileChip`, `ActivityFeedStatusChip`). */
98
+ title: React.ReactNode
99
+ /** Relative time string (e.g. `"4 min ago"`). Rendered as a muted
100
+ * suffix on the title row — the wrapping `gap-x-2` provides the
101
+ * visual separation from the title, no separator glyph is used. */
102
+ timestamp?: React.ReactNode
103
+ /** Right slot — typically a `MoreHorizontal` IconButton trigger. */
104
+ actions?: React.ReactNode
105
+ }
106
+
107
+ const ActivityFeedItem = React.forwardRef<HTMLLIElement, ActivityFeedItemProps>(
108
+ ({ className, avatar, title, timestamp, actions, children, ...props }, ref) => (
109
+ <li
110
+ ref={ref}
111
+ data-slot="activity-feed-item"
112
+ className={cn('flex items-start gap-3', className)}
113
+ {...props}
114
+ >
115
+ {avatar ? (
116
+ <span
117
+ data-slot="activity-feed-item-avatar"
118
+ className="mt-0.5 inline-flex shrink-0 items-center justify-center"
119
+ >
120
+ {avatar}
121
+ </span>
122
+ ) : null}
123
+ <div data-slot="activity-feed-item-body" className="flex min-w-0 flex-1 flex-col gap-2">
124
+ <div
125
+ data-slot="activity-feed-item-title"
126
+ className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-6 text-foreground"
127
+ >
128
+ <span className="font-semibold">{title}</span>
129
+ {timestamp ? (
130
+ <span
131
+ data-slot="activity-feed-item-timestamp"
132
+ className="font-normal text-muted-foreground"
133
+ >
134
+ {timestamp}
135
+ </span>
136
+ ) : null}
137
+ </div>
138
+ {children ? (
139
+ <div data-slot="activity-feed-item-content" className="flex flex-wrap gap-2">
140
+ {children}
141
+ </div>
142
+ ) : null}
143
+ </div>
144
+ {actions ? (
145
+ <div
146
+ data-slot="activity-feed-item-actions"
147
+ className="ml-auto inline-flex shrink-0 items-start"
148
+ >
149
+ {actions}
150
+ </div>
151
+ ) : null}
152
+ </li>
153
+ ),
154
+ )
155
+ ActivityFeedItem.displayName = 'ActivityFeedItem'
156
+
157
+ export type ActivityFeedFileChipProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> & {
158
+ /** Filename rendered after the paperclip icon (e.g. `"apex-report.pdf"`). */
159
+ name: React.ReactNode
160
+ /** Optional size badge in muted text (e.g. `"4mb"`). */
161
+ size?: React.ReactNode
162
+ /** Click handler for the trailing download icon-button. When omitted
163
+ * the download button is not rendered. */
164
+ onDownload?: () => void
165
+ /** Accessible label for the download button. Default `"Download"`. */
166
+ downloadAriaLabel?: string
167
+ }
168
+
169
+ const ActivityFeedFileChip = React.forwardRef<HTMLDivElement, ActivityFeedFileChipProps>(
170
+ (
171
+ { className, name, size, onDownload, downloadAriaLabel = 'Download', ...props },
172
+ ref,
173
+ ) => (
174
+ <div
175
+ ref={ref}
176
+ data-slot="activity-feed-file-chip"
177
+ className={cn(
178
+ 'inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground shadow-xs',
179
+ className,
180
+ )}
181
+ {...props}
182
+ >
183
+ <Paperclip aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
184
+ <span className="truncate font-medium">{name}</span>
185
+ {size ? (
186
+ <span data-slot="activity-feed-file-chip-size" className="text-muted-foreground">
187
+ ({size})
188
+ </span>
189
+ ) : null}
190
+ {onDownload ? (
191
+ <button
192
+ type="button"
193
+ data-slot="activity-feed-file-chip-download"
194
+ aria-label={downloadAriaLabel}
195
+ onClick={onDownload}
196
+ className={cn(
197
+ 'ml-1 inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors',
198
+ 'hover:bg-muted/40 hover:text-foreground focus-visible:shadow-focus',
199
+ )}
200
+ >
201
+ <Download aria-hidden="true" className="size-3.5" />
202
+ </button>
203
+ ) : null}
204
+ </div>
205
+ ),
206
+ )
207
+ ActivityFeedFileChip.displayName = 'ActivityFeedFileChip'
208
+
209
+ export type ActivityFeedCommentProps = React.HTMLAttributes<HTMLDivElement> & {
210
+ /** Optional leading icon. Default: speech bubble (`MessageCircle`). */
211
+ icon?: React.ReactNode
212
+ /** Click handler for the trailing `Reply` link. When omitted no
213
+ * Reply button is rendered. */
214
+ onReply?: () => void
215
+ /** Custom label for the reply affordance. Default `"Reply"`. */
216
+ replyLabel?: React.ReactNode
217
+ }
218
+
219
+ const ActivityFeedComment = React.forwardRef<HTMLDivElement, ActivityFeedCommentProps>(
220
+ ({ className, icon, onReply, replyLabel = 'Reply', children, ...props }, ref) => (
221
+ <div
222
+ ref={ref}
223
+ data-slot="activity-feed-comment"
224
+ className={cn(
225
+ 'inline-flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs',
226
+ className,
227
+ )}
228
+ {...props}
229
+ >
230
+ <span data-slot="activity-feed-comment-icon" aria-hidden="true" className="shrink-0">
231
+ {icon ?? <MessageCircle className="size-4 text-muted-foreground" />}
232
+ </span>
233
+ <span data-slot="activity-feed-comment-body" className="min-w-0 flex-1 truncate">
234
+ {children}
235
+ </span>
236
+ {onReply ? (
237
+ <button
238
+ type="button"
239
+ data-slot="activity-feed-comment-reply"
240
+ onClick={onReply}
241
+ className={cn(
242
+ 'shrink-0 rounded-md text-sm font-medium text-accent-indigo outline-none transition-colors',
243
+ 'hover:text-accent-indigo/80 focus-visible:shadow-focus',
244
+ )}
245
+ >
246
+ {replyLabel}
247
+ </button>
248
+ ) : null}
249
+ </div>
250
+ ),
251
+ )
252
+ ActivityFeedComment.displayName = 'ActivityFeedComment'
253
+
254
+ const STATUS_ICON_MAP: Record<
255
+ 'success' | 'warning' | 'info' | 'error' | 'neutral',
256
+ React.ComponentType<{ className?: string; 'aria-hidden'?: 'true' | 'false' }>
257
+ > = {
258
+ success: CheckCircle2,
259
+ warning: AlertTriangle,
260
+ info: Clock,
261
+ error: XCircle,
262
+ neutral: Clock,
263
+ }
264
+
265
+ const STATUS_TONE_MAP: Record<
266
+ 'success' | 'warning' | 'info' | 'error' | 'neutral',
267
+ string
268
+ > = {
269
+ // Icons keep the semantic color; chip surface stays neutral so the
270
+ // visual emphasis sits with the icon (matches Figma `Task Status
271
+ // Items [1.1]` — chips are bordered white pills, the status lives
272
+ // in the icon color).
273
+ success: 'text-status-success-icon',
274
+ warning: 'text-status-warning-icon',
275
+ info: 'text-status-info-icon',
276
+ error: 'text-status-error-icon',
277
+ neutral: 'text-muted-foreground',
278
+ }
279
+
280
+ export type ActivityFeedStatusChipProps = Omit<
281
+ React.HTMLAttributes<HTMLSpanElement>,
282
+ 'children'
283
+ > & {
284
+ status?: 'success' | 'warning' | 'info' | 'error' | 'neutral'
285
+ /** Override the auto status icon. */
286
+ icon?: React.ReactNode
287
+ /** Chip label. */
288
+ children: React.ReactNode
289
+ }
290
+
291
+ const ActivityFeedStatusChip = React.forwardRef<HTMLSpanElement, ActivityFeedStatusChipProps>(
292
+ ({ className, status = 'neutral', icon, children, ...props }, ref) => {
293
+ const Icon = STATUS_ICON_MAP[status]
294
+ const iconTone = STATUS_TONE_MAP[status]
295
+ return (
296
+ <span
297
+ ref={ref}
298
+ data-slot="activity-feed-status-chip"
299
+ data-status={status}
300
+ className={cn(
301
+ 'inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1 text-sm font-medium text-foreground shadow-xs',
302
+ className,
303
+ )}
304
+ {...props}
305
+ >
306
+ <span
307
+ data-slot="activity-feed-status-chip-icon"
308
+ aria-hidden="true"
309
+ className={cn('inline-flex shrink-0 items-center justify-center', iconTone)}
310
+ >
311
+ {icon ?? <Icon aria-hidden="true" className="size-4" />}
312
+ </span>
313
+ <span>{children}</span>
314
+ </span>
315
+ )
316
+ },
317
+ )
318
+ ActivityFeedStatusChip.displayName = 'ActivityFeedStatusChip'
319
+
320
+ export {
321
+ ActivityFeed,
322
+ ActivityFeedItem,
323
+ ActivityFeedFileChip,
324
+ ActivityFeedComment,
325
+ ActivityFeedStatusChip,
326
+ }