@startsimpli/ui 0.4.6 → 0.4.8

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 (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,327 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react'
4
+ import { Search } from 'lucide-react'
5
+ import { useCommandPalette } from './command-palette-context'
6
+ import { useCommandPaletteSearch } from './useCommandPaletteSearch'
7
+ import { CommandGroup } from './CommandGroup'
8
+ import { CommandResultItem } from './CommandResultItem'
9
+ import type { CommandItem } from './useCommandPaletteSearch'
10
+
11
+ export interface SearchResult {
12
+ id: string
13
+ type: string
14
+ name: string
15
+ detail: string
16
+ path: string
17
+ }
18
+
19
+ export interface QuickAction {
20
+ id: string
21
+ icon: React.ElementType
22
+ name: string
23
+ shortcut?: string
24
+ action: () => void
25
+ }
26
+
27
+ export interface NavigationItem {
28
+ id: string
29
+ icon: React.ElementType
30
+ name: string
31
+ path: string
32
+ }
33
+
34
+ export interface CommandPaletteProps {
35
+ searchFn?: (query: string) => Promise<SearchResult[]>
36
+ quickActions?: QuickAction[]
37
+ navigationItems?: NavigationItem[]
38
+ recentItems?: SearchResult[]
39
+ typeIcons?: Record<string, React.ElementType>
40
+ typeColors?: Record<string, string>
41
+ placeholder?: string
42
+ onNavigate: (path: string) => void
43
+ }
44
+
45
+ /**
46
+ * Convert the heterogeneous props into a unified CommandItem[] for the hook.
47
+ * Async search results are injected separately since they arrive after debounce.
48
+ */
49
+ function buildCommands(
50
+ results: SearchResult[],
51
+ quickActions: QuickAction[],
52
+ navigationItems: NavigationItem[],
53
+ recentItems: SearchResult[],
54
+ hasQuery: boolean,
55
+ typeIcons: Record<string, React.ElementType>,
56
+ typeColors: Record<string, string>,
57
+ ): CommandItem[] {
58
+ const commands: CommandItem[] = []
59
+
60
+ // Search results (only when query is active - injected externally)
61
+ for (const r of results) {
62
+ commands.push({
63
+ id: r.id,
64
+ icon: typeIcons[r.type],
65
+ label: r.name,
66
+ detail: r.detail,
67
+ group: '__results__',
68
+ onSelect: r.path,
69
+ colorClass: typeColors[r.type] || 'bg-gray-100 text-gray-600',
70
+ })
71
+ }
72
+
73
+ // Quick actions (always shown)
74
+ for (const a of quickActions) {
75
+ commands.push({
76
+ id: a.id,
77
+ icon: a.icon,
78
+ label: a.name,
79
+ shortcut: a.shortcut,
80
+ group: 'Quick Actions',
81
+ onSelect: () => a.action(),
82
+ colorClass: 'bg-gray-100 text-gray-600',
83
+ })
84
+ }
85
+
86
+ // Recent items (only when no query)
87
+ if (!hasQuery) {
88
+ for (const r of recentItems.slice(0, 5)) {
89
+ commands.push({
90
+ id: r.id,
91
+ icon: typeIcons[r.type],
92
+ label: r.name,
93
+ detail: r.detail,
94
+ group: 'Recent',
95
+ onSelect: r.path,
96
+ colorClass: 'bg-gray-100 text-gray-600',
97
+ })
98
+ }
99
+ }
100
+
101
+ // Navigation items (only included when query is active - filtered by hook)
102
+ if (hasQuery) {
103
+ for (const n of navigationItems) {
104
+ commands.push({
105
+ id: n.id,
106
+ icon: n.icon,
107
+ label: n.name,
108
+ group: 'Go to',
109
+ onSelect: n.path,
110
+ colorClass: 'bg-gray-100 text-gray-600',
111
+ })
112
+ }
113
+ }
114
+
115
+ return commands
116
+ }
117
+
118
+ export function CommandPalette({
119
+ searchFn,
120
+ quickActions = [],
121
+ navigationItems = [],
122
+ recentItems = [],
123
+ typeIcons = {},
124
+ typeColors = {},
125
+ placeholder = 'Search or type a command...',
126
+ onNavigate,
127
+ }: CommandPaletteProps) {
128
+ const { isOpen, close } = useCommandPalette()
129
+ const inputRef = useRef<HTMLInputElement>(null)
130
+ const [asyncResults, setAsyncResults] = useState<SearchResult[]>([])
131
+ const [isLoading, setIsLoading] = useState(false)
132
+ // Track query locally so we can build commands with the right hasQuery flag
133
+ const [localQuery, setLocalQuery] = useState('')
134
+
135
+ const commands = useMemo(
136
+ () =>
137
+ buildCommands(
138
+ asyncResults,
139
+ quickActions,
140
+ navigationItems,
141
+ recentItems,
142
+ !!localQuery,
143
+ typeIcons,
144
+ typeColors,
145
+ ),
146
+ [asyncResults, quickActions, navigationItems, recentItems, localQuery, typeIcons, typeColors]
147
+ )
148
+
149
+ const handleExecute = useCallback(
150
+ (command: CommandItem) => {
151
+ if (typeof command.onSelect === 'string') {
152
+ onNavigate(command.onSelect)
153
+ } else {
154
+ command.onSelect()
155
+ }
156
+ close()
157
+ },
158
+ [onNavigate, close]
159
+ )
160
+
161
+ const {
162
+ query,
163
+ setQuery,
164
+ filteredGroups,
165
+ selectedIndex,
166
+ onKeyDown,
167
+ } = useCommandPaletteSearch({
168
+ commands,
169
+ onExecute: handleExecute,
170
+ isOpen,
171
+ })
172
+
173
+ // Sync local query with hook query
174
+ useEffect(() => {
175
+ setLocalQuery(query)
176
+ }, [query])
177
+
178
+ // Focus input when opened
179
+ useEffect(() => {
180
+ if (isOpen) {
181
+ setAsyncResults([])
182
+ setLocalQuery('')
183
+ setTimeout(() => inputRef.current?.focus(), 0)
184
+ }
185
+ }, [isOpen])
186
+
187
+ // Search debounce for async results
188
+ useEffect(() => {
189
+ if (!query.trim() || !searchFn) {
190
+ setAsyncResults([])
191
+ return
192
+ }
193
+
194
+ const timer = setTimeout(async () => {
195
+ setIsLoading(true)
196
+ try {
197
+ const data = await searchFn(query)
198
+ setAsyncResults(data)
199
+ } catch (error) {
200
+ console.error('Search error:', error)
201
+ setAsyncResults([])
202
+ } finally {
203
+ setIsLoading(false)
204
+ }
205
+ }, 200)
206
+
207
+ return () => clearTimeout(timer)
208
+ }, [query, searchFn])
209
+
210
+ if (!isOpen) return null
211
+
212
+ // Compute flat index offset for each group to match selectedIndex
213
+ let flatIndex = 0
214
+
215
+ return (
216
+ <div
217
+ className="fixed inset-0 z-50 flex items-start justify-center pt-20"
218
+ onClick={close}
219
+ >
220
+ {/* Backdrop */}
221
+ <div className="absolute inset-0 bg-black/50 backdrop-blur-sm pointer-events-none" />
222
+
223
+ {/* Palette */}
224
+ <div
225
+ role="dialog"
226
+ aria-modal="true"
227
+ aria-label="Command palette"
228
+ className="relative w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
229
+ onClick={(e) => e.stopPropagation()}
230
+ >
231
+ {/* Search input */}
232
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
233
+ <Search className="w-5 h-5 text-gray-400" />
234
+ <input
235
+ ref={inputRef}
236
+ type="text"
237
+ value={query}
238
+ onChange={(e) => {
239
+ setQuery(e.target.value)
240
+ setLocalQuery(e.target.value)
241
+ }}
242
+ onKeyDown={onKeyDown}
243
+ placeholder={placeholder}
244
+ className="flex-1 outline-none text-lg bg-transparent"
245
+ />
246
+ <kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded">ESC</kbd>
247
+ </div>
248
+
249
+ {/* Results */}
250
+ <div role="listbox" aria-label="Search results" className="max-h-96 overflow-y-auto">
251
+ {/* Search results group header with query text */}
252
+ {query && asyncResults.length > 0 && (
253
+ <div className="px-3 py-2">
254
+ <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
255
+ Results for &quot;{query}&quot;
256
+ </p>
257
+ </div>
258
+ )}
259
+
260
+ {/* Loading state */}
261
+ {isLoading && (
262
+ <div className="px-4 py-3 text-gray-500 text-sm">
263
+ Searching...
264
+ </div>
265
+ )}
266
+
267
+ {/* No results */}
268
+ {query && !isLoading && asyncResults.length === 0 && (
269
+ <div className="px-4 py-3 text-gray-500 text-sm">
270
+ No results found for &quot;{query}&quot;
271
+ </div>
272
+ )}
273
+
274
+ {filteredGroups.map((group, gi) => {
275
+ // Skip the __results__ group label -- we render a custom header above
276
+ const isResultsGroup = group.label === '__results__'
277
+ const showBorder = !isResultsGroup && gi > 0
278
+
279
+ const groupStartIndex = flatIndex
280
+
281
+ const renderedItems = group.items.map((item, i) => {
282
+ const itemIndex = groupStartIndex + i
283
+ const showArrow = typeof item.onSelect === 'string'
284
+
285
+ return (
286
+ <CommandResultItem
287
+ key={item.id}
288
+ icon={item.icon}
289
+ label={item.label}
290
+ detail={item.detail}
291
+ shortcut={item.shortcut}
292
+ selected={selectedIndex === itemIndex}
293
+ colorClass={item.colorClass}
294
+ showArrow={showArrow}
295
+ onClick={() => handleExecute(item)}
296
+ />
297
+ )
298
+ })
299
+
300
+ flatIndex += group.items.length
301
+
302
+ if (isResultsGroup) {
303
+ // Results rendered with custom header above
304
+ return <Fragment key={group.label}>{renderedItems}</Fragment>
305
+ }
306
+
307
+ return (
308
+ <CommandGroup key={group.label} label={group.label} showBorder={showBorder}>
309
+ {renderedItems}
310
+ </CommandGroup>
311
+ )
312
+ })}
313
+ </div>
314
+
315
+ {/* Footer */}
316
+ <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
317
+ <div className="flex items-center gap-4">
318
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">&uarr;&darr;</kbd> Navigate</span>
319
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">&crarr;</kbd> Select</span>
320
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">ESC</kbd> Close</span>
321
+ </div>
322
+ <span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded">&#8984;K</kbd> anywhere</span>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ )
327
+ }
@@ -0,0 +1,59 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { Search, ArrowRight } from 'lucide-react'
5
+
6
+ export interface CommandResultItemProps {
7
+ icon?: React.ElementType
8
+ label: string
9
+ detail?: string
10
+ shortcut?: string
11
+ selected: boolean
12
+ /** Color class for icon container, e.g. 'bg-blue-100 text-blue-600' */
13
+ colorClass?: string
14
+ /** Whether to show a trailing arrow (for navigation items) */
15
+ showArrow?: boolean
16
+ onClick: () => void
17
+ }
18
+
19
+ export function CommandResultItem({
20
+ icon,
21
+ label,
22
+ detail,
23
+ shortcut,
24
+ selected,
25
+ colorClass = 'bg-gray-100 text-gray-600',
26
+ showArrow = false,
27
+ onClick,
28
+ }: CommandResultItemProps) {
29
+ const Icon = icon || Search
30
+
31
+ return (
32
+ <div
33
+ role="option"
34
+ aria-selected={selected}
35
+ className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
36
+ selected ? 'bg-blue-50' : 'hover:bg-gray-50'
37
+ }`}
38
+ onClick={onClick}
39
+ >
40
+ <div
41
+ className={`w-8 h-8 rounded-full flex items-center justify-center ${colorClass}`}
42
+ >
43
+ <Icon className="w-4 h-4" />
44
+ </div>
45
+ <div className="flex-1">
46
+ <p className={detail ? 'font-medium text-gray-900' : 'font-medium text-gray-700'}>
47
+ {label}
48
+ </p>
49
+ {detail && <p className="text-sm text-gray-500">{detail}</p>}
50
+ </div>
51
+ {shortcut && (
52
+ <kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded font-mono">
53
+ {shortcut}
54
+ </kbd>
55
+ )}
56
+ {showArrow && <ArrowRight className="w-4 h-4 text-gray-400" />}
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,81 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { CommandGroup } from '../CommandGroup'
3
+
4
+ describe('CommandGroup', () => {
5
+ describe('label', () => {
6
+ it('renders the provided label text', () => {
7
+ render(<CommandGroup label="Navigation"><div /></CommandGroup>)
8
+ expect(screen.getByText('Navigation')).toBeInTheDocument()
9
+ })
10
+
11
+ it('renders label in uppercase via CSS class', () => {
12
+ render(<CommandGroup label="Actions"><div /></CommandGroup>)
13
+ const label = screen.getByText('Actions')
14
+ expect(label.className).toMatch(/uppercase/)
15
+ })
16
+
17
+ it('applies text-xs styling to the label', () => {
18
+ render(<CommandGroup label="Recent"><div /></CommandGroup>)
19
+ const label = screen.getByText('Recent')
20
+ expect(label.className).toMatch(/text-xs/)
21
+ })
22
+ })
23
+
24
+ describe('children', () => {
25
+ it('renders children inside the fragment', () => {
26
+ render(
27
+ <CommandGroup label="Navigation">
28
+ <div data-testid="child-item">Item A</div>
29
+ </CommandGroup>
30
+ )
31
+ expect(screen.getByTestId('child-item')).toBeInTheDocument()
32
+ })
33
+
34
+ it('renders multiple children', () => {
35
+ render(
36
+ <CommandGroup label="Navigation">
37
+ <div data-testid="item-1">One</div>
38
+ <div data-testid="item-2">Two</div>
39
+ <div data-testid="item-3">Three</div>
40
+ </CommandGroup>
41
+ )
42
+ expect(screen.getByTestId('item-1')).toBeInTheDocument()
43
+ expect(screen.getByTestId('item-2')).toBeInTheDocument()
44
+ expect(screen.getByTestId('item-3')).toBeInTheDocument()
45
+ })
46
+ })
47
+
48
+ describe('showBorder prop', () => {
49
+ it('does not add border class by default', () => {
50
+ const { container } = render(
51
+ <CommandGroup label="First"><div /></CommandGroup>
52
+ )
53
+ const header = container.querySelector('.px-3.py-2')
54
+ expect(header?.className).not.toMatch(/border-t/)
55
+ })
56
+
57
+ it('does not add border class when showBorder is false', () => {
58
+ const { container } = render(
59
+ <CommandGroup label="First" showBorder={false}><div /></CommandGroup>
60
+ )
61
+ const header = container.querySelector('.px-3.py-2')
62
+ expect(header?.className).not.toMatch(/border-t/)
63
+ })
64
+
65
+ it('adds border-t class when showBorder is true', () => {
66
+ const { container } = render(
67
+ <CommandGroup label="Second" showBorder><div /></CommandGroup>
68
+ )
69
+ const header = container.querySelector('.px-3.py-2')
70
+ expect(header?.className).toMatch(/border-t/)
71
+ })
72
+
73
+ it('adds border-gray-100 class when showBorder is true', () => {
74
+ const { container } = render(
75
+ <CommandGroup label="Second" showBorder><div /></CommandGroup>
76
+ )
77
+ const header = container.querySelector('.px-3.py-2')
78
+ expect(header?.className).toMatch(/border-gray-100/)
79
+ })
80
+ })
81
+ })
@@ -0,0 +1,166 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { CommandResultItem } from '../CommandResultItem'
3
+
4
+ // lucide-react icons are SVGs — mock them as simple elements so tests are
5
+ // not sensitive to SVG implementation details while still being renderable.
6
+ jest.mock('lucide-react', () => ({
7
+ Search: (props: React.SVGProps<SVGSVGElement>) => (
8
+ <svg data-testid="icon-search" {...props} />
9
+ ),
10
+ ArrowRight: (props: React.SVGProps<SVGSVGElement>) => (
11
+ <svg data-testid="icon-arrow-right" {...props} />
12
+ ),
13
+ }))
14
+
15
+ const StarIcon = (props: React.SVGProps<SVGSVGElement>) => (
16
+ <svg data-testid="icon-star" {...props} />
17
+ )
18
+
19
+ describe('CommandResultItem', () => {
20
+ const defaultProps = {
21
+ label: 'Go to Dashboard',
22
+ selected: false,
23
+ onClick: jest.fn(),
24
+ }
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks()
28
+ })
29
+
30
+ describe('label', () => {
31
+ it('renders the label text', () => {
32
+ render(<CommandResultItem {...defaultProps} />)
33
+ expect(screen.getByText('Go to Dashboard')).toBeInTheDocument()
34
+ })
35
+ })
36
+
37
+ describe('detail', () => {
38
+ it('renders detail text when provided', () => {
39
+ render(<CommandResultItem {...defaultProps} detail="Overview of metrics" />)
40
+ expect(screen.getByText('Overview of metrics')).toBeInTheDocument()
41
+ })
42
+
43
+ it('does not render detail element when omitted', () => {
44
+ render(<CommandResultItem {...defaultProps} />)
45
+ // Only the label paragraph should be present; no second <p>
46
+ const paragraphs = document.querySelectorAll('p')
47
+ expect(paragraphs).toHaveLength(1)
48
+ })
49
+ })
50
+
51
+ describe('shortcut', () => {
52
+ it('renders the shortcut in a kbd element when provided', () => {
53
+ render(<CommandResultItem {...defaultProps} shortcut="⌘K" />)
54
+ const kbd = screen.getByText('⌘K')
55
+ expect(kbd.tagName).toBe('KBD')
56
+ })
57
+
58
+ it('does not render a kbd element when shortcut is omitted', () => {
59
+ const { container } = render(<CommandResultItem {...defaultProps} />)
60
+ expect(container.querySelector('kbd')).not.toBeInTheDocument()
61
+ })
62
+ })
63
+
64
+ describe('icon', () => {
65
+ it('renders the provided icon', () => {
66
+ render(<CommandResultItem {...defaultProps} icon={StarIcon} />)
67
+ expect(screen.getByTestId('icon-star')).toBeInTheDocument()
68
+ })
69
+
70
+ it('falls back to Search icon when no icon prop is given', () => {
71
+ render(<CommandResultItem {...defaultProps} />)
72
+ expect(screen.getByTestId('icon-search')).toBeInTheDocument()
73
+ })
74
+ })
75
+
76
+ describe('showArrow', () => {
77
+ it('does not render ArrowRight by default', () => {
78
+ render(<CommandResultItem {...defaultProps} />)
79
+ expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument()
80
+ })
81
+
82
+ it('renders ArrowRight when showArrow is true', () => {
83
+ render(<CommandResultItem {...defaultProps} showArrow />)
84
+ expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument()
85
+ })
86
+ })
87
+
88
+ describe('selected state', () => {
89
+ it('sets aria-selected="true" when selected', () => {
90
+ render(<CommandResultItem {...defaultProps} selected />)
91
+ const item = screen.getByRole('option')
92
+ expect(item).toHaveAttribute('aria-selected', 'true')
93
+ })
94
+
95
+ it('sets aria-selected="false" when not selected', () => {
96
+ render(<CommandResultItem {...defaultProps} selected={false} />)
97
+ const item = screen.getByRole('option')
98
+ expect(item).toHaveAttribute('aria-selected', 'false')
99
+ })
100
+
101
+ it('applies bg-blue-50 highlight class when selected', () => {
102
+ render(<CommandResultItem {...defaultProps} selected />)
103
+ const item = screen.getByRole('option')
104
+ expect(item.className).toMatch(/bg-blue-50/)
105
+ })
106
+
107
+ it('does not apply bg-blue-50 when not selected', () => {
108
+ render(<CommandResultItem {...defaultProps} selected={false} />)
109
+ const item = screen.getByRole('option')
110
+ expect(item.className).not.toMatch(/bg-blue-50/)
111
+ })
112
+ })
113
+
114
+ describe('colorClass', () => {
115
+ it('applies the default color class to the icon container', () => {
116
+ const { container } = render(<CommandResultItem {...defaultProps} />)
117
+ const iconContainer = container.querySelector('.w-8.h-8')
118
+ expect(iconContainer?.className).toMatch(/bg-gray-100/)
119
+ expect(iconContainer?.className).toMatch(/text-gray-600/)
120
+ })
121
+
122
+ it('applies a custom colorClass to the icon container', () => {
123
+ const { container } = render(
124
+ <CommandResultItem {...defaultProps} colorClass="bg-blue-100 text-blue-600" />
125
+ )
126
+ const iconContainer = container.querySelector('.w-8.h-8')
127
+ expect(iconContainer?.className).toMatch(/bg-blue-100/)
128
+ expect(iconContainer?.className).toMatch(/text-blue-600/)
129
+ })
130
+ })
131
+
132
+ describe('onClick', () => {
133
+ it('fires onClick when the item is clicked', () => {
134
+ const onClick = jest.fn()
135
+ render(<CommandResultItem {...defaultProps} onClick={onClick} />)
136
+ fireEvent.click(screen.getByRole('option'))
137
+ expect(onClick).toHaveBeenCalledTimes(1)
138
+ })
139
+
140
+ it('does not fire onClick when a different element is clicked', () => {
141
+ const onClick = jest.fn()
142
+ render(
143
+ <div>
144
+ <CommandResultItem {...defaultProps} onClick={onClick} label="Target" />
145
+ <button data-testid="other">Other</button>
146
+ </div>
147
+ )
148
+ fireEvent.click(screen.getByTestId('other'))
149
+ expect(onClick).not.toHaveBeenCalled()
150
+ })
151
+ })
152
+
153
+ describe('label typography without detail', () => {
154
+ it('uses text-gray-700 on label when no detail is present', () => {
155
+ render(<CommandResultItem {...defaultProps} />)
156
+ const label = screen.getByText('Go to Dashboard')
157
+ expect(label.className).toMatch(/text-gray-700/)
158
+ })
159
+
160
+ it('uses text-gray-900 on label when detail is present', () => {
161
+ render(<CommandResultItem {...defaultProps} detail="some detail" />)
162
+ const label = screen.getByText('Go to Dashboard')
163
+ expect(label.className).toMatch(/text-gray-900/)
164
+ })
165
+ })
166
+ })