@startsimpli/ui 0.4.7 → 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 (61) hide show
  1. package/package.json +1 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  61. 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, ArrowRight } from 'lucide-react'
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 [query, setQuery] = useState('')
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
- // Calculate all items for keyboard navigation
59
- const filteredNav = query
60
- ? navigationItems.filter(n => n.name.toLowerCase().includes(query.toLowerCase()))
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
- const allItems = [
64
- ...results.map(r => ({ ...r, itemType: 'result' as const })),
65
- ...quickActions.map(a => ({ ...a, itemType: 'action' as const })),
66
- ...(!query ? recentItems.slice(0, 5).map(r => ({ ...r, itemType: 'recent' as const })) : []),
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
- setQuery('')
74
- setSelectedIndex(0)
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
- setResults([])
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
- setResults(data)
198
+ setAsyncResults(data)
92
199
  } catch (error) {
93
200
  console.error('Search error:', error)
94
- setResults([])
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
- const DefaultIcon = Search
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) => setQuery(e.target.value)}
156
- onKeyDown={handleKeyDown}
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 Results */}
166
- {query && results.length > 0 && (
167
- <>
168
- <div className="px-3 py-2">
169
- <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
170
- Results for &quot;{query}&quot;
171
- </p>
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 &quot;{query}&quot;
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 && results.length === 0 && (
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 &quot;{query}&quot;
216
271
  </div>
217
272
  )}
218
273
 
219
- {/* Quick Actions */}
220
- {quickActions.length > 0 && (
221
- <>
222
- <div className="px-3 py-2 border-t border-gray-100">
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
- {/* Recent Items (when no query) */}
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
- {/* Filtered Navigation (when query matches) */}
297
- {filteredNav.length > 0 && (
298
- <>
299
- <div className="px-3 py-2 border-t border-gray-100">
300
- <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
301
- Go to
302
- </p>
303
- </div>
304
- {filteredNav.map((navItem, i) => {
305
- const itemIndex = results.length + quickActions.length + i
306
-
307
- return (
308
- <div
309
- key={navItem.id}
310
- role="option"
311
- aria-selected={selectedIndex === itemIndex}
312
- className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
313
- selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
314
- }`}
315
- onClick={() => {
316
- onNavigate(navItem.path)
317
- close()
318
- }}
319
- >
320
- <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
321
- <navItem.icon className="w-4 h-4 text-gray-600" />
322
- </div>
323
- <p className="flex-1 font-medium text-gray-700">{navItem.name}</p>
324
- <ArrowRight className="w-4 h-4 text-gray-400" />
325
- </div>
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">&crarr;</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">&amp;#8984;K</kbd> anywhere</span>
322
+ <span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded">&#8984;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
+ })