@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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
|
|
1998
|
-
|
|
1999
|
-
<
|
|
2000
|
-
{
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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 ?
|
|
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 &&
|
|
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 ?
|
|
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 &&
|
|
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-
|
|
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
|
})
|