@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- 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
|
+
}
|