@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.
- package/package.json +2 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +327 -0
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +9 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +20 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +66 -0
- package/src/components/email-editor/email-editor.tsx +497 -0
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +51 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +140 -887
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/settings/index.ts +6 -0
- 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 "{query}"
|
|
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 "{query}"
|
|
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">↑↓</kbd> Navigate</span>
|
|
319
|
+
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">↵</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">⌘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
|
+
})
|