@startsimpli/ui 0.4.7 → 0.4.9
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 +21 -23
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +183 -200
- 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/index.ts +6 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -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 +6 -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-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/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/editor-sidebar.tsx +6 -731
- package/src/components/email-editor/email-editor.tsx +78 -467
- 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 +1 -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/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +134 -881
- 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/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -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/__tests__/pipeline.test.tsx +169 -0
- package/src/components/safe-html.tsx +9 -8
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
4
|
-
import { Search
|
|
3
|
+
import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react'
|
|
4
|
+
import { Search } from 'lucide-react'
|
|
5
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'
|
|
6
10
|
|
|
7
11
|
export interface SearchResult {
|
|
8
12
|
id: string
|
|
@@ -38,6 +42,79 @@ export interface CommandPaletteProps {
|
|
|
38
42
|
onNavigate: (path: string) => void
|
|
39
43
|
}
|
|
40
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
|
+
|
|
41
118
|
export function CommandPalette({
|
|
42
119
|
searchFn,
|
|
43
120
|
quickActions = [],
|
|
@@ -50,37 +127,67 @@ export function CommandPalette({
|
|
|
50
127
|
}: CommandPaletteProps) {
|
|
51
128
|
const { isOpen, close } = useCommandPalette()
|
|
52
129
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
53
|
-
const [
|
|
54
|
-
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
55
|
-
const [results, setResults] = useState<SearchResult[]>([])
|
|
130
|
+
const [asyncResults, setAsyncResults] = useState<SearchResult[]>([])
|
|
56
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
|
+
)
|
|
57
160
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
161
|
+
const {
|
|
162
|
+
query,
|
|
163
|
+
setQuery,
|
|
164
|
+
filteredGroups,
|
|
165
|
+
selectedIndex,
|
|
166
|
+
onKeyDown,
|
|
167
|
+
} = useCommandPaletteSearch({
|
|
168
|
+
commands,
|
|
169
|
+
onExecute: handleExecute,
|
|
170
|
+
isOpen,
|
|
171
|
+
})
|
|
62
172
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
...filteredNav.map(n => ({ ...n, itemType: 'nav' as const })),
|
|
68
|
-
]
|
|
173
|
+
// Sync local query with hook query
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
setLocalQuery(query)
|
|
176
|
+
}, [query])
|
|
69
177
|
|
|
70
178
|
// Focus input when opened
|
|
71
179
|
useEffect(() => {
|
|
72
180
|
if (isOpen) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
setResults([])
|
|
181
|
+
setAsyncResults([])
|
|
182
|
+
setLocalQuery('')
|
|
76
183
|
setTimeout(() => inputRef.current?.focus(), 0)
|
|
77
184
|
}
|
|
78
185
|
}, [isOpen])
|
|
79
186
|
|
|
80
|
-
// Search debounce
|
|
187
|
+
// Search debounce for async results
|
|
81
188
|
useEffect(() => {
|
|
82
189
|
if (!query.trim() || !searchFn) {
|
|
83
|
-
|
|
190
|
+
setAsyncResults([])
|
|
84
191
|
return
|
|
85
192
|
}
|
|
86
193
|
|
|
@@ -88,10 +195,10 @@ export function CommandPalette({
|
|
|
88
195
|
setIsLoading(true)
|
|
89
196
|
try {
|
|
90
197
|
const data = await searchFn(query)
|
|
91
|
-
|
|
198
|
+
setAsyncResults(data)
|
|
92
199
|
} catch (error) {
|
|
93
200
|
console.error('Search error:', error)
|
|
94
|
-
|
|
201
|
+
setAsyncResults([])
|
|
95
202
|
} finally {
|
|
96
203
|
setIsLoading(false)
|
|
97
204
|
}
|
|
@@ -100,34 +207,10 @@ export function CommandPalette({
|
|
|
100
207
|
return () => clearTimeout(timer)
|
|
101
208
|
}, [query, searchFn])
|
|
102
209
|
|
|
103
|
-
// Keyboard navigation
|
|
104
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
105
|
-
if (e.key === 'ArrowDown') {
|
|
106
|
-
e.preventDefault()
|
|
107
|
-
setSelectedIndex(prev => Math.min(prev + 1, allItems.length - 1))
|
|
108
|
-
} else if (e.key === 'ArrowUp') {
|
|
109
|
-
e.preventDefault()
|
|
110
|
-
setSelectedIndex(prev => Math.max(prev - 1, 0))
|
|
111
|
-
} else if (e.key === 'Enter' && allItems[selectedIndex]) {
|
|
112
|
-
e.preventDefault()
|
|
113
|
-
const item = allItems[selectedIndex]
|
|
114
|
-
if (item.itemType === 'action') {
|
|
115
|
-
(item as unknown as QuickAction).action()
|
|
116
|
-
} else if ('path' in item) {
|
|
117
|
-
onNavigate(item.path)
|
|
118
|
-
}
|
|
119
|
-
close()
|
|
120
|
-
}
|
|
121
|
-
}, [allItems, selectedIndex, onNavigate, close])
|
|
122
|
-
|
|
123
|
-
// Reset selection when results change
|
|
124
|
-
useEffect(() => {
|
|
125
|
-
setSelectedIndex(0)
|
|
126
|
-
}, [results])
|
|
127
|
-
|
|
128
210
|
if (!isOpen) return null
|
|
129
211
|
|
|
130
|
-
|
|
212
|
+
// Compute flat index offset for each group to match selectedIndex
|
|
213
|
+
let flatIndex = 0
|
|
131
214
|
|
|
132
215
|
return (
|
|
133
216
|
<div
|
|
@@ -143,7 +226,7 @@ export function CommandPalette({
|
|
|
143
226
|
aria-modal="true"
|
|
144
227
|
aria-label="Command palette"
|
|
145
228
|
className="relative w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
|
|
146
|
-
onClick={e => e.stopPropagation()}
|
|
229
|
+
onClick={(e) => e.stopPropagation()}
|
|
147
230
|
>
|
|
148
231
|
{/* Search input */}
|
|
149
232
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
|
|
@@ -152,8 +235,11 @@ export function CommandPalette({
|
|
|
152
235
|
ref={inputRef}
|
|
153
236
|
type="text"
|
|
154
237
|
value={query}
|
|
155
|
-
onChange={(e) =>
|
|
156
|
-
|
|
238
|
+
onChange={(e) => {
|
|
239
|
+
setQuery(e.target.value)
|
|
240
|
+
setLocalQuery(e.target.value)
|
|
241
|
+
}}
|
|
242
|
+
onKeyDown={onKeyDown}
|
|
157
243
|
placeholder={placeholder}
|
|
158
244
|
className="flex-1 outline-none text-lg bg-transparent"
|
|
159
245
|
/>
|
|
@@ -162,44 +248,13 @@ export function CommandPalette({
|
|
|
162
248
|
|
|
163
249
|
{/* Results */}
|
|
164
250
|
<div role="listbox" aria-label="Search results" className="max-h-96 overflow-y-auto">
|
|
165
|
-
{/* Search
|
|
166
|
-
{query &&
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
</div>
|
|
173
|
-
{results.map((result, i) => {
|
|
174
|
-
const Icon = typeIcons[result.type] || DefaultIcon
|
|
175
|
-
const colorClass = typeColors[result.type] || 'bg-gray-100 text-gray-600'
|
|
176
|
-
const itemIndex = i
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<div
|
|
180
|
-
key={result.id}
|
|
181
|
-
role="option"
|
|
182
|
-
aria-selected={selectedIndex === itemIndex}
|
|
183
|
-
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
184
|
-
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
185
|
-
}`}
|
|
186
|
-
onClick={() => {
|
|
187
|
-
onNavigate(result.path)
|
|
188
|
-
close()
|
|
189
|
-
}}
|
|
190
|
-
>
|
|
191
|
-
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${colorClass}`}>
|
|
192
|
-
<Icon className="w-4 h-4" />
|
|
193
|
-
</div>
|
|
194
|
-
<div className="flex-1">
|
|
195
|
-
<p className="font-medium text-gray-900">{result.name}</p>
|
|
196
|
-
<p className="text-sm text-gray-500">{result.detail}</p>
|
|
197
|
-
</div>
|
|
198
|
-
<ArrowRight className="w-4 h-4 text-gray-400" />
|
|
199
|
-
</div>
|
|
200
|
-
)
|
|
201
|
-
})}
|
|
202
|
-
</>
|
|
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>
|
|
203
258
|
)}
|
|
204
259
|
|
|
205
260
|
{/* Loading state */}
|
|
@@ -210,123 +265,51 @@ export function CommandPalette({
|
|
|
210
265
|
)}
|
|
211
266
|
|
|
212
267
|
{/* No results */}
|
|
213
|
-
{query && !isLoading &&
|
|
268
|
+
{query && !isLoading && asyncResults.length === 0 && (
|
|
214
269
|
<div className="px-4 py-3 text-gray-500 text-sm">
|
|
215
270
|
No results found for "{query}"
|
|
216
271
|
</div>
|
|
217
272
|
)}
|
|
218
273
|
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
224
|
-
Quick Actions
|
|
225
|
-
</p>
|
|
226
|
-
</div>
|
|
227
|
-
{quickActions.map((action, i) => {
|
|
228
|
-
const itemIndex = results.length + i
|
|
229
|
-
return (
|
|
230
|
-
<div
|
|
231
|
-
key={action.id}
|
|
232
|
-
role="option"
|
|
233
|
-
aria-selected={selectedIndex === itemIndex}
|
|
234
|
-
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
235
|
-
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
236
|
-
}`}
|
|
237
|
-
onClick={() => {
|
|
238
|
-
action.action()
|
|
239
|
-
close()
|
|
240
|
-
}}
|
|
241
|
-
>
|
|
242
|
-
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
243
|
-
<action.icon className="w-4 h-4 text-gray-600" />
|
|
244
|
-
</div>
|
|
245
|
-
<p className="flex-1 font-medium text-gray-700">{action.name}</p>
|
|
246
|
-
{action.shortcut && (
|
|
247
|
-
<kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded font-mono">
|
|
248
|
-
{action.shortcut}
|
|
249
|
-
</kbd>
|
|
250
|
-
)}
|
|
251
|
-
</div>
|
|
252
|
-
)
|
|
253
|
-
})}
|
|
254
|
-
</>
|
|
255
|
-
)}
|
|
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
|
|
256
278
|
|
|
257
|
-
|
|
258
|
-
{!query && recentItems.length > 0 && (
|
|
259
|
-
<>
|
|
260
|
-
<div className="px-3 py-2 border-t border-gray-100">
|
|
261
|
-
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
262
|
-
Recent
|
|
263
|
-
</p>
|
|
264
|
-
</div>
|
|
265
|
-
{recentItems.slice(0, 5).map((item, i) => {
|
|
266
|
-
const Icon = typeIcons[item.type] || DefaultIcon
|
|
267
|
-
const itemIndex = results.length + quickActions.length + i
|
|
268
|
-
|
|
269
|
-
return (
|
|
270
|
-
<div
|
|
271
|
-
key={item.id}
|
|
272
|
-
role="option"
|
|
273
|
-
aria-selected={selectedIndex === itemIndex}
|
|
274
|
-
className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
|
|
275
|
-
selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
276
|
-
}`}
|
|
277
|
-
onClick={() => {
|
|
278
|
-
onNavigate(item.path)
|
|
279
|
-
close()
|
|
280
|
-
}}
|
|
281
|
-
>
|
|
282
|
-
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
283
|
-
<Icon className="w-4 h-4 text-gray-600" />
|
|
284
|
-
</div>
|
|
285
|
-
<div className="flex-1">
|
|
286
|
-
<p className="font-medium text-gray-700">{item.name}</p>
|
|
287
|
-
<p className="text-sm text-gray-500">{item.detail}</p>
|
|
288
|
-
</div>
|
|
289
|
-
<ArrowRight className="w-4 h-4 text-gray-400" />
|
|
290
|
-
</div>
|
|
291
|
-
)
|
|
292
|
-
})}
|
|
293
|
-
</>
|
|
294
|
-
)}
|
|
279
|
+
const groupStartIndex = flatIndex
|
|
295
280
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
</>
|
|
329
|
-
)}
|
|
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
|
+
})}
|
|
330
313
|
</div>
|
|
331
314
|
|
|
332
315
|
{/* Footer */}
|
|
@@ -336,7 +319,7 @@ export function CommandPalette({
|
|
|
336
319
|
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">↵</kbd> Select</span>
|
|
337
320
|
<span><kbd className="px-1.5 py-0.5 bg-white border rounded">ESC</kbd> Close</span>
|
|
338
321
|
</div>
|
|
339
|
-
<span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded"
|
|
322
|
+
<span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded">⌘K</kbd> anywhere</span>
|
|
340
323
|
</div>
|
|
341
324
|
</div>
|
|
342
325
|
</div>
|
|
@@ -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
|
+
})
|