@open-mercato/ui 0.5.1-develop.2663.2c29774b5b → 0.5.1-develop.2681.c559bb2bc3
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 +2 -2
- package/dist/backend/CrudForm.js +187 -39
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/Page.js +12 -4
- package/dist/backend/Page.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/CollapsibleGroup.js +88 -0
- package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
- package/dist/backend/crud/useGroupCollapse.js +24 -0
- package/dist/backend/crud/useGroupCollapse.js.map +7 -0
- package/dist/backend/crud/useGroupOrder.js +61 -0
- package/dist/backend/crud/useGroupOrder.js.map +7 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
- package/dist/backend/crud/useZoneCollapse.js +24 -0
- package/dist/backend/crud/useZoneCollapse.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +77 -33
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +82 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +16 -2
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/primitives/avatar.js +59 -0
- package/dist/primitives/avatar.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +230 -21
- package/src/backend/Page.tsx +20 -4
- package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
- package/src/backend/__tests__/NotesSection.test.tsx +63 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
- package/src/backend/crud/CollapsibleGroup.tsx +111 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
- package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
- package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
- package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
- package/src/backend/crud/useGroupCollapse.ts +22 -0
- package/src/backend/crud/useGroupOrder.ts +74 -0
- package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
- package/src/backend/crud/useZoneCollapse.ts +22 -0
- package/src/backend/detail/AttachmentsSection.tsx +81 -38
- package/src/backend/detail/NotesSection.tsx +99 -6
- package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
- package/src/primitives/__tests__/avatar.test.tsx +64 -0
- package/src/primitives/avatar.tsx +75 -0
|
@@ -64,6 +64,19 @@ export type NotesUpdatePayload = {
|
|
|
64
64
|
|
|
65
65
|
export type NotesDataAdapter<C = unknown> = {
|
|
66
66
|
list: (params: { entityId: string | null; dealId: string | null; context?: C }) => Promise<CommentSummary[]>
|
|
67
|
+
listPage?: (params: {
|
|
68
|
+
entityId: string | null
|
|
69
|
+
dealId: string | null
|
|
70
|
+
page: number
|
|
71
|
+
pageSize: number
|
|
72
|
+
context?: C
|
|
73
|
+
}) => Promise<{
|
|
74
|
+
items: CommentSummary[]
|
|
75
|
+
total: number
|
|
76
|
+
page: number
|
|
77
|
+
pageSize: number
|
|
78
|
+
totalPages: number
|
|
79
|
+
}>
|
|
67
80
|
create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
|
|
68
81
|
update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
|
|
69
82
|
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
@@ -446,7 +459,10 @@ function NotesSectionImpl<C = unknown>({
|
|
|
446
459
|
const [contentError, setContentError] = React.useState<string | null>(null)
|
|
447
460
|
const contentTextareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
448
461
|
const [visibleCount, setVisibleCount] = React.useState(0)
|
|
462
|
+
const [currentPage, setCurrentPage] = React.useState(1)
|
|
463
|
+
const [totalPages, setTotalPages] = React.useState(1)
|
|
449
464
|
const [deletingNoteId, setDeletingNoteId] = React.useState<string | null>(null)
|
|
465
|
+
const pagedMode = typeof dataAdapter.listPage === 'function'
|
|
450
466
|
|
|
451
467
|
React.useEffect(() => {
|
|
452
468
|
const queryEntityId = typeof entityId === 'string' ? entityId : ''
|
|
@@ -455,6 +471,8 @@ function NotesSectionImpl<C = unknown>({
|
|
|
455
471
|
setNotes([])
|
|
456
472
|
setLoadError(null)
|
|
457
473
|
setIsLoading(false)
|
|
474
|
+
setCurrentPage(1)
|
|
475
|
+
setTotalPages(1)
|
|
458
476
|
return
|
|
459
477
|
}
|
|
460
478
|
let cancelled = false
|
|
@@ -463,6 +481,20 @@ function NotesSectionImpl<C = unknown>({
|
|
|
463
481
|
pushLoading()
|
|
464
482
|
async function loadNotes() {
|
|
465
483
|
try {
|
|
484
|
+
if (dataAdapter.listPage) {
|
|
485
|
+
const pageResult = await dataAdapter.listPage({
|
|
486
|
+
entityId: queryEntityId || null,
|
|
487
|
+
dealId: queryDealId || null,
|
|
488
|
+
page: 1,
|
|
489
|
+
pageSize: 20,
|
|
490
|
+
context: dataContext,
|
|
491
|
+
})
|
|
492
|
+
if (cancelled) return
|
|
493
|
+
setNotes(pageResult.items)
|
|
494
|
+
setCurrentPage(pageResult.page)
|
|
495
|
+
setTotalPages(pageResult.totalPages)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
466
498
|
const mapped = await dataAdapter.list({
|
|
467
499
|
entityId: queryEntityId || null,
|
|
468
500
|
dealId: queryDealId || null,
|
|
@@ -470,12 +502,16 @@ function NotesSectionImpl<C = unknown>({
|
|
|
470
502
|
})
|
|
471
503
|
if (cancelled) return
|
|
472
504
|
setNotes(mapped)
|
|
505
|
+
setCurrentPage(1)
|
|
506
|
+
setTotalPages(1)
|
|
473
507
|
} catch (err) {
|
|
474
508
|
if (cancelled) return
|
|
475
509
|
const message =
|
|
476
510
|
err instanceof Error ? err.message : label('loadError', 'Failed to load notes.')
|
|
477
511
|
setNotes([])
|
|
478
512
|
setLoadError(message)
|
|
513
|
+
setCurrentPage(1)
|
|
514
|
+
setTotalPages(1)
|
|
479
515
|
flash(message, 'error')
|
|
480
516
|
} finally {
|
|
481
517
|
if (!cancelled) setIsLoading(false)
|
|
@@ -486,7 +522,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
486
522
|
return () => {
|
|
487
523
|
cancelled = true
|
|
488
524
|
}
|
|
489
|
-
}, [dataAdapter, dataContext, dealId, entityId, popLoading, pushLoading
|
|
525
|
+
}, [dataAdapter, dataContext, dealId, entityId, label, popLoading, pushLoading])
|
|
490
526
|
|
|
491
527
|
const youLabel = label('you', 'You')
|
|
492
528
|
const viewerLabel = React.useMemo(() => viewerName ?? viewerEmail ?? null, [viewerEmail, viewerName])
|
|
@@ -534,6 +570,10 @@ function NotesSectionImpl<C = unknown>({
|
|
|
534
570
|
}, [readMarkdownPreference])
|
|
535
571
|
|
|
536
572
|
React.useEffect(() => {
|
|
573
|
+
if (pagedMode) {
|
|
574
|
+
setVisibleCount(notes.length)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
537
577
|
if (!notes.length) {
|
|
538
578
|
setVisibleCount(0)
|
|
539
579
|
return
|
|
@@ -543,7 +583,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
543
583
|
if (prev >= notes.length) return prev
|
|
544
584
|
return Math.min(Math.max(prev, baseline), notes.length)
|
|
545
585
|
})
|
|
546
|
-
}, [notes.length])
|
|
586
|
+
}, [notes.length, pagedMode])
|
|
547
587
|
|
|
548
588
|
React.useEffect(() => {
|
|
549
589
|
if (hasEntity) return
|
|
@@ -553,8 +593,14 @@ function NotesSectionImpl<C = unknown>({
|
|
|
553
593
|
setDraftColor(null)
|
|
554
594
|
}, [hasEntity])
|
|
555
595
|
|
|
556
|
-
const visibleNotes = React.useMemo(
|
|
557
|
-
|
|
596
|
+
const visibleNotes = React.useMemo(
|
|
597
|
+
() => (pagedMode ? notes : notes.slice(0, visibleCount)),
|
|
598
|
+
[notes, pagedMode, visibleCount],
|
|
599
|
+
)
|
|
600
|
+
const hasVisibleNotes = React.useMemo(
|
|
601
|
+
() => (pagedMode ? notes.length > 0 : visibleCount > 0),
|
|
602
|
+
[notes.length, pagedMode, visibleCount],
|
|
603
|
+
)
|
|
558
604
|
|
|
559
605
|
const loadMoreLabel = label('loadMore')
|
|
560
606
|
|
|
@@ -618,6 +664,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
618
664
|
}
|
|
619
665
|
return [newNote, ...prev]
|
|
620
666
|
})
|
|
667
|
+
setVisibleCount((prev) => Math.max(prev, 1))
|
|
621
668
|
flash(label('success'), 'success')
|
|
622
669
|
return true
|
|
623
670
|
} catch (err) {
|
|
@@ -686,6 +733,9 @@ function NotesSectionImpl<C = unknown>({
|
|
|
686
733
|
try {
|
|
687
734
|
await dataAdapter.delete({ id: note.id, context: dataContext })
|
|
688
735
|
setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
|
|
736
|
+
if (pagedMode) {
|
|
737
|
+
setVisibleCount((prev) => Math.max(0, prev - 1))
|
|
738
|
+
}
|
|
689
739
|
flash(label('deleteSuccess', 'Note deleted'), 'success')
|
|
690
740
|
} catch (err) {
|
|
691
741
|
const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
|
|
@@ -716,11 +766,40 @@ function NotesSectionImpl<C = unknown>({
|
|
|
716
766
|
)
|
|
717
767
|
|
|
718
768
|
const handleLoadMore = React.useCallback(() => {
|
|
769
|
+
if (pagedMode && dataAdapter.listPage) {
|
|
770
|
+
if (currentPage >= totalPages || isLoading) return
|
|
771
|
+
const queryEntityId = typeof entityId === 'string' ? entityId : ''
|
|
772
|
+
const queryDealId = typeof dealId === 'string' ? dealId : ''
|
|
773
|
+
setIsLoading(true)
|
|
774
|
+
pushLoading()
|
|
775
|
+
void dataAdapter.listPage({
|
|
776
|
+
entityId: queryEntityId || null,
|
|
777
|
+
dealId: queryDealId || null,
|
|
778
|
+
page: currentPage + 1,
|
|
779
|
+
pageSize: 20,
|
|
780
|
+
context: dataContext,
|
|
781
|
+
})
|
|
782
|
+
.then((pageResult) => {
|
|
783
|
+
setNotes((prev) => [...prev, ...pageResult.items])
|
|
784
|
+
setCurrentPage(pageResult.page)
|
|
785
|
+
setTotalPages(pageResult.totalPages)
|
|
786
|
+
})
|
|
787
|
+
.catch((error) => {
|
|
788
|
+
const message =
|
|
789
|
+
error instanceof Error ? error.message : label('loadError', 'Failed to load notes.')
|
|
790
|
+
flash(message, 'error')
|
|
791
|
+
})
|
|
792
|
+
.finally(() => {
|
|
793
|
+
setIsLoading(false)
|
|
794
|
+
popLoading()
|
|
795
|
+
})
|
|
796
|
+
return
|
|
797
|
+
}
|
|
719
798
|
setVisibleCount((prev) => {
|
|
720
799
|
if (prev >= notes.length) return prev
|
|
721
800
|
return Math.min(prev + 5, notes.length)
|
|
722
801
|
})
|
|
723
|
-
}, [notes.length])
|
|
802
|
+
}, [currentPage, dataAdapter, dataContext, dealId, entityId, flash, isLoading, label, notes.length, pagedMode, popLoading, pushLoading, totalPages])
|
|
724
803
|
|
|
725
804
|
const handleAppearanceDialogSubmit = React.useCallback(async () => {
|
|
726
805
|
if (!appearanceDialogState) return
|
|
@@ -1034,6 +1113,20 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1034
1113
|
{loadError ? <ErrorMessage label={loadError} className="mt-3" /> : null}
|
|
1035
1114
|
|
|
1036
1115
|
<div className="space-y-3">
|
|
1116
|
+
{!composerOpen && hasVisibleNotes && !onActionChange ? (
|
|
1117
|
+
<div className="flex justify-end">
|
|
1118
|
+
<Button
|
|
1119
|
+
type="button"
|
|
1120
|
+
variant="outline"
|
|
1121
|
+
size="sm"
|
|
1122
|
+
onClick={focusComposer}
|
|
1123
|
+
disabled={isSubmitting || isLoading || !hasEntity}
|
|
1124
|
+
>
|
|
1125
|
+
<Plus className="size-4" />
|
|
1126
|
+
{addActionLabel}
|
|
1127
|
+
</Button>
|
|
1128
|
+
</div>
|
|
1129
|
+
) : null}
|
|
1037
1130
|
{isLoading ? (
|
|
1038
1131
|
<LoadingMessage
|
|
1039
1132
|
label={label('loading', 'Loading notes…')}
|
|
@@ -1206,7 +1299,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1206
1299
|
}}
|
|
1207
1300
|
/>
|
|
1208
1301
|
)}
|
|
1209
|
-
{isLoading || visibleCount >= notes.length ? null : (
|
|
1302
|
+
{isLoading || (pagedMode ? currentPage >= totalPages : visibleCount >= notes.length) ? null : (
|
|
1210
1303
|
<div className="flex justify-center">
|
|
1211
1304
|
<Button variant="outline" size="sm" onClick={handleLoadMore}>
|
|
1212
1305
|
{loadMoreLabel}
|
|
@@ -43,15 +43,18 @@ import {
|
|
|
43
43
|
Coins,
|
|
44
44
|
Copy,
|
|
45
45
|
CreditCard,
|
|
46
|
+
Crown,
|
|
46
47
|
Database,
|
|
47
48
|
DollarSign,
|
|
48
49
|
Download,
|
|
49
50
|
ExternalLink,
|
|
51
|
+
Eye,
|
|
50
52
|
FileMinus,
|
|
51
53
|
FilePenLine,
|
|
52
54
|
FileText,
|
|
53
55
|
FilterX,
|
|
54
56
|
Flag,
|
|
57
|
+
Flame,
|
|
55
58
|
FolderTree,
|
|
56
59
|
Gauge,
|
|
57
60
|
GitBranch,
|
|
@@ -61,7 +64,9 @@ import {
|
|
|
61
64
|
GraduationCap,
|
|
62
65
|
Hand,
|
|
63
66
|
Handshake,
|
|
67
|
+
Headphones,
|
|
64
68
|
Heart,
|
|
69
|
+
HelpCircle,
|
|
65
70
|
Hourglass,
|
|
66
71
|
Inbox,
|
|
67
72
|
Key,
|
|
@@ -77,7 +82,7 @@ import {
|
|
|
77
82
|
Mail,
|
|
78
83
|
MailOpen,
|
|
79
84
|
MapPin,
|
|
80
|
-
|
|
85
|
+
MinusCircle,
|
|
81
86
|
Notebook,
|
|
82
87
|
Package,
|
|
83
88
|
PackageCheck,
|
|
@@ -106,6 +111,7 @@ import {
|
|
|
106
111
|
Shuffle,
|
|
107
112
|
Sliders,
|
|
108
113
|
Smartphone,
|
|
114
|
+
Snowflake,
|
|
109
115
|
Sparkles,
|
|
110
116
|
Star,
|
|
111
117
|
StickyNote,
|
|
@@ -127,6 +133,7 @@ import {
|
|
|
127
133
|
UserPlus,
|
|
128
134
|
UserRound,
|
|
129
135
|
Users,
|
|
136
|
+
UserX,
|
|
130
137
|
Wallet,
|
|
131
138
|
Webhook,
|
|
132
139
|
Wrench,
|
|
@@ -173,15 +180,18 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
173
180
|
'coins': Coins,
|
|
174
181
|
'copy': Copy,
|
|
175
182
|
'credit-card': CreditCard,
|
|
183
|
+
'crown': Crown,
|
|
176
184
|
'database': Database,
|
|
177
185
|
'dollar-sign': DollarSign,
|
|
178
186
|
'download': Download,
|
|
179
187
|
'external-link': ExternalLink,
|
|
188
|
+
'eye': Eye,
|
|
180
189
|
'file-minus': FileMinus,
|
|
181
190
|
'file-pen-line': FilePenLine,
|
|
182
191
|
'file-text': FileText,
|
|
183
192
|
'filter-x': FilterX,
|
|
184
193
|
'flag': Flag,
|
|
194
|
+
'flame': Flame,
|
|
185
195
|
'folder-tree': FolderTree,
|
|
186
196
|
'gauge': Gauge,
|
|
187
197
|
'git-branch': GitBranch,
|
|
@@ -191,7 +201,9 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
191
201
|
'graduation-cap': GraduationCap,
|
|
192
202
|
'hand': Hand,
|
|
193
203
|
'handshake': Handshake,
|
|
204
|
+
'headphones': Headphones,
|
|
194
205
|
'heart': Heart,
|
|
206
|
+
'help-circle': HelpCircle,
|
|
195
207
|
'hourglass': Hourglass,
|
|
196
208
|
'inbox': Inbox,
|
|
197
209
|
'key': Key,
|
|
@@ -207,7 +219,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
207
219
|
'mail': Mail,
|
|
208
220
|
'mail-open': MailOpen,
|
|
209
221
|
'map-pin': MapPin,
|
|
210
|
-
'
|
|
222
|
+
'minus-circle': MinusCircle,
|
|
211
223
|
'notebook': Notebook,
|
|
212
224
|
'package': Package,
|
|
213
225
|
'package-check': PackageCheck,
|
|
@@ -236,6 +248,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
236
248
|
'shuffle': Shuffle,
|
|
237
249
|
'sliders': Sliders,
|
|
238
250
|
'smartphone': Smartphone,
|
|
251
|
+
'snowflake': Snowflake,
|
|
239
252
|
'sparkles': Sparkles,
|
|
240
253
|
'star': Star,
|
|
241
254
|
'sticky-note': StickyNote,
|
|
@@ -256,6 +269,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
256
269
|
'user-minus': UserMinus,
|
|
257
270
|
'user-plus': UserPlus,
|
|
258
271
|
'user-round': UserRound,
|
|
272
|
+
'user-x': UserX,
|
|
259
273
|
'users': Users,
|
|
260
274
|
'wallet': Wallet,
|
|
261
275
|
'webhook': Webhook,
|
|
@@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'
|
|
|
5
5
|
import type { PluggableList } from 'unified'
|
|
6
6
|
import { LoadingMessage } from '../detail/LoadingMessage'
|
|
7
7
|
import { useMarkdownRemarkPlugins } from '../markdown/useMarkdownRemarkPlugins'
|
|
8
|
+
import { useTheme } from '../../theme'
|
|
8
9
|
|
|
9
10
|
export type SwitchableMarkdownInputProps = {
|
|
10
11
|
value: string
|
|
@@ -69,6 +70,7 @@ export function SwitchableMarkdownInput({
|
|
|
69
70
|
remarkPlugins,
|
|
70
71
|
}: SwitchableMarkdownInputProps) {
|
|
71
72
|
const resolvedPlugins = useMarkdownRemarkPlugins(remarkPlugins)
|
|
73
|
+
const { resolvedTheme } = useTheme()
|
|
72
74
|
const editorWrapperClasses =
|
|
73
75
|
editorWrapperClassName ?? 'w-full rounded-lg border border-muted-foreground/20 bg-background p-2'
|
|
74
76
|
const editorClasses = editorClassName ?? 'w-full'
|
|
@@ -79,7 +81,7 @@ export function SwitchableMarkdownInput({
|
|
|
79
81
|
if (isMarkdownEnabled && !disableMarkdown) {
|
|
80
82
|
return (
|
|
81
83
|
<div className={editorWrapperClasses}>
|
|
82
|
-
<div data-color-mode=
|
|
84
|
+
<div data-color-mode={resolvedTheme} className={editorClasses}>
|
|
83
85
|
<UiMarkdownEditor
|
|
84
86
|
value={value}
|
|
85
87
|
height={height}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { Avatar } from '../avatar'
|
|
4
|
+
|
|
5
|
+
describe('Avatar', () => {
|
|
6
|
+
it('renders two-character initials for multi-word labels', () => {
|
|
7
|
+
render(<Avatar label="Jan Kowalski" />)
|
|
8
|
+
expect(screen.getByText('JK')).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('renders first two characters for single-word labels', () => {
|
|
12
|
+
render(<Avatar label="Acme" />)
|
|
13
|
+
expect(screen.getByText('AC')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders a question mark when label is empty', () => {
|
|
17
|
+
render(<Avatar label=" " />)
|
|
18
|
+
expect(screen.getByText('?')).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('uses the provided ariaLabel over the label for a11y', () => {
|
|
22
|
+
render(<Avatar label="Jan Kowalski" ariaLabel="Owner avatar" />)
|
|
23
|
+
expect(screen.getByRole('img', { name: 'Owner avatar' })).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to label for aria when ariaLabel is not provided', () => {
|
|
27
|
+
render(<Avatar label="Acme Corp" />)
|
|
28
|
+
expect(screen.getByRole('img', { name: 'Acme Corp' })).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('applies monochrome classes when variant=monochrome', () => {
|
|
32
|
+
const { container } = render(<Avatar label="Jan" variant="monochrome" />)
|
|
33
|
+
const root = container.firstChild as HTMLElement
|
|
34
|
+
expect(root.className).toContain('bg-muted')
|
|
35
|
+
expect(root.className).toContain('text-muted-foreground')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('applies default (colored) classes when variant is default', () => {
|
|
39
|
+
const { container } = render(<Avatar label="Jan" />)
|
|
40
|
+
const root = container.firstChild as HTMLElement
|
|
41
|
+
expect(root.className).toContain('bg-primary/10')
|
|
42
|
+
expect(root.className).toContain('text-primary')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders an icon in place of initials when icon is provided', () => {
|
|
46
|
+
const Icon = () => <svg data-testid="bldg" />
|
|
47
|
+
render(<Avatar label="Acme" icon={<Icon />} />)
|
|
48
|
+
expect(screen.getByTestId('bldg')).toBeInTheDocument()
|
|
49
|
+
expect(screen.queryByText('AC')).not.toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders an image when src is provided', () => {
|
|
53
|
+
const { container } = render(<Avatar label="Jan" src="/avatar.png" />)
|
|
54
|
+
const img = container.querySelector('img')
|
|
55
|
+
expect(img).toBeTruthy()
|
|
56
|
+
expect(img?.getAttribute('src')).toBe('/avatar.png')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('applies size classes from size prop', () => {
|
|
60
|
+
const { container } = render(<Avatar label="Jan" size="lg" />)
|
|
61
|
+
const root = container.firstChild as HTMLElement
|
|
62
|
+
expect(root.className).toContain('size-12')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
4
|
+
|
|
5
|
+
const avatarVariants = cva(
|
|
6
|
+
'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold select-none',
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
size: {
|
|
10
|
+
xs: 'size-5 text-xs',
|
|
11
|
+
sm: 'size-7 text-xs',
|
|
12
|
+
md: 'size-9 text-sm',
|
|
13
|
+
lg: 'size-12 text-base',
|
|
14
|
+
xl: 'size-16 text-xl',
|
|
15
|
+
},
|
|
16
|
+
variant: {
|
|
17
|
+
default: 'bg-primary/10 text-primary',
|
|
18
|
+
monochrome: 'bg-muted text-muted-foreground',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
size: 'md',
|
|
23
|
+
variant: 'default',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function computeInitials(label: string): string {
|
|
29
|
+
const trimmed = label.trim()
|
|
30
|
+
if (!trimmed.length) return '?'
|
|
31
|
+
const parts = trimmed.split(/\s+/).filter(Boolean)
|
|
32
|
+
if (parts.length === 1) {
|
|
33
|
+
return parts[0].slice(0, 2).toUpperCase()
|
|
34
|
+
}
|
|
35
|
+
const first = parts[0][0] ?? ''
|
|
36
|
+
const last = parts[parts.length - 1][0] ?? ''
|
|
37
|
+
return (first + last).toUpperCase()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AvatarProps = {
|
|
41
|
+
label: string
|
|
42
|
+
src?: string | null
|
|
43
|
+
icon?: React.ReactNode
|
|
44
|
+
ariaLabel?: string
|
|
45
|
+
} & VariantProps<typeof avatarVariants> &
|
|
46
|
+
Omit<React.HTMLAttributes<HTMLDivElement>, 'role' | 'aria-label'>
|
|
47
|
+
|
|
48
|
+
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
|
49
|
+
({ className, label, src, icon, size, variant, ariaLabel, ...rest }, ref) => {
|
|
50
|
+
const initials = React.useMemo(() => computeInitials(label), [label])
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
ref={ref}
|
|
54
|
+
role="img"
|
|
55
|
+
aria-label={ariaLabel ?? label}
|
|
56
|
+
className={cn(avatarVariants({ size, variant }), className)}
|
|
57
|
+
{...rest}
|
|
58
|
+
>
|
|
59
|
+
{src ? (
|
|
60
|
+
<img src={src} alt="" className="size-full object-cover" aria-hidden="true" />
|
|
61
|
+
) : icon ? (
|
|
62
|
+
<span aria-hidden="true" className="flex items-center justify-center [&>svg]:size-[55%]">
|
|
63
|
+
{icon}
|
|
64
|
+
</span>
|
|
65
|
+
) : (
|
|
66
|
+
<span aria-hidden="true">{initials}</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
Avatar.displayName = 'Avatar'
|
|
74
|
+
|
|
75
|
+
export { avatarVariants }
|