@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,166 @@
1
+ import { render, screen, act } from '@testing-library/react'
2
+ import { fireEvent } from '@testing-library/react'
3
+ import { renderHook } from '@testing-library/react'
4
+ import { CommandPaletteProvider, useCommandPalette } from '../command-palette-context'
5
+
6
+ // Wrapper that exposes the context value through data attributes so we can
7
+ // assert on it without tying tests to component internals.
8
+ function ContextProbe() {
9
+ const { isOpen, open, close, toggle } = useCommandPalette()
10
+ return (
11
+ <div
12
+ data-testid="probe"
13
+ data-open={String(isOpen)}
14
+ >
15
+ <button data-testid="btn-open" onClick={open}>open</button>
16
+ <button data-testid="btn-close" onClick={close}>close</button>
17
+ <button data-testid="btn-toggle" onClick={toggle}>toggle</button>
18
+ </div>
19
+ )
20
+ }
21
+
22
+ function renderWithProvider() {
23
+ return render(
24
+ <CommandPaletteProvider>
25
+ <ContextProbe />
26
+ </CommandPaletteProvider>
27
+ )
28
+ }
29
+
30
+ function probe() {
31
+ return screen.getByTestId('probe')
32
+ }
33
+
34
+ describe('CommandPaletteProvider / useCommandPalette', () => {
35
+ describe('initial state', () => {
36
+ it('starts with isOpen = false', () => {
37
+ renderWithProvider()
38
+ expect(probe().dataset.open).toBe('false')
39
+ })
40
+ })
41
+
42
+ describe('open()', () => {
43
+ it('sets isOpen to true', () => {
44
+ renderWithProvider()
45
+ fireEvent.click(screen.getByTestId('btn-open'))
46
+ expect(probe().dataset.open).toBe('true')
47
+ })
48
+
49
+ it('is idempotent — calling open twice leaves isOpen true', () => {
50
+ renderWithProvider()
51
+ fireEvent.click(screen.getByTestId('btn-open'))
52
+ fireEvent.click(screen.getByTestId('btn-open'))
53
+ expect(probe().dataset.open).toBe('true')
54
+ })
55
+ })
56
+
57
+ describe('close()', () => {
58
+ it('sets isOpen to false', () => {
59
+ renderWithProvider()
60
+ fireEvent.click(screen.getByTestId('btn-open'))
61
+ fireEvent.click(screen.getByTestId('btn-close'))
62
+ expect(probe().dataset.open).toBe('false')
63
+ })
64
+
65
+ it('is idempotent — calling close when already closed keeps isOpen false', () => {
66
+ renderWithProvider()
67
+ fireEvent.click(screen.getByTestId('btn-close'))
68
+ expect(probe().dataset.open).toBe('false')
69
+ })
70
+ })
71
+
72
+ describe('toggle()', () => {
73
+ it('opens when currently closed', () => {
74
+ renderWithProvider()
75
+ fireEvent.click(screen.getByTestId('btn-toggle'))
76
+ expect(probe().dataset.open).toBe('true')
77
+ })
78
+
79
+ it('closes when currently open', () => {
80
+ renderWithProvider()
81
+ fireEvent.click(screen.getByTestId('btn-open'))
82
+ fireEvent.click(screen.getByTestId('btn-toggle'))
83
+ expect(probe().dataset.open).toBe('false')
84
+ })
85
+
86
+ it('toggles back and forth correctly', () => {
87
+ renderWithProvider()
88
+ const btn = screen.getByTestId('btn-toggle')
89
+ fireEvent.click(btn)
90
+ expect(probe().dataset.open).toBe('true')
91
+ fireEvent.click(btn)
92
+ expect(probe().dataset.open).toBe('false')
93
+ fireEvent.click(btn)
94
+ expect(probe().dataset.open).toBe('true')
95
+ })
96
+ })
97
+
98
+ describe('Cmd+K / Ctrl+K keyboard shortcut', () => {
99
+ it('toggles isOpen on Cmd+K (metaKey)', () => {
100
+ renderWithProvider()
101
+ act(() => {
102
+ fireEvent.keyDown(document, { key: 'k', metaKey: true })
103
+ })
104
+ expect(probe().dataset.open).toBe('true')
105
+ })
106
+
107
+ it('toggles isOpen on Ctrl+K (ctrlKey)', () => {
108
+ renderWithProvider()
109
+ act(() => {
110
+ fireEvent.keyDown(document, { key: 'k', ctrlKey: true })
111
+ })
112
+ expect(probe().dataset.open).toBe('true')
113
+ })
114
+
115
+ it('toggles closed on second Cmd+K', () => {
116
+ renderWithProvider()
117
+ act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
118
+ act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
119
+ expect(probe().dataset.open).toBe('false')
120
+ })
121
+
122
+ it('does not toggle on plain k keypress', () => {
123
+ renderWithProvider()
124
+ act(() => { fireEvent.keyDown(document, { key: 'k' }) })
125
+ expect(probe().dataset.open).toBe('false')
126
+ })
127
+
128
+ it('removes the global keydown listener on unmount (no state update after unmount)', () => {
129
+ const { unmount } = renderWithProvider()
130
+ unmount()
131
+ // Should not throw when dispatching after unmount
132
+ expect(() => {
133
+ act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
134
+ }).not.toThrow()
135
+ })
136
+ })
137
+
138
+ describe('Escape key', () => {
139
+ it('closes the palette when open', () => {
140
+ renderWithProvider()
141
+ // Open it first
142
+ fireEvent.click(screen.getByTestId('btn-open'))
143
+ expect(probe().dataset.open).toBe('true')
144
+
145
+ act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
146
+ expect(probe().dataset.open).toBe('false')
147
+ })
148
+
149
+ it('does nothing when palette is already closed', () => {
150
+ renderWithProvider()
151
+ act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
152
+ expect(probe().dataset.open).toBe('false')
153
+ })
154
+ })
155
+
156
+ describe('useCommandPalette — usage outside provider', () => {
157
+ it('throws when used outside CommandPaletteProvider', () => {
158
+ // Suppress the expected React error boundary console output
159
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
160
+ expect(() => {
161
+ renderHook(() => useCommandPalette())
162
+ }).toThrow('useCommandPalette must be used within a CommandPaletteProvider')
163
+ spy.mockRestore()
164
+ })
165
+ })
166
+ })
@@ -0,0 +1,271 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useCommandPaletteSearch, CommandItem } from '../useCommandPaletteSearch'
3
+
4
+ const Home = () => null
5
+ Home.displayName = 'Home'
6
+
7
+ const Settings = () => null
8
+ Settings.displayName = 'Settings'
9
+
10
+ const COMMANDS: CommandItem[] = [
11
+ {
12
+ id: 'nav-home',
13
+ label: 'Go to Home',
14
+ detail: 'Dashboard overview',
15
+ group: 'Navigation',
16
+ onSelect: '/home',
17
+ icon: Home,
18
+ },
19
+ {
20
+ id: 'nav-settings',
21
+ label: 'Go to Settings',
22
+ detail: 'Account preferences',
23
+ group: 'Navigation',
24
+ onSelect: '/settings',
25
+ icon: Settings,
26
+ },
27
+ {
28
+ id: 'action-invite',
29
+ label: 'Invite User',
30
+ detail: 'Send invite email',
31
+ group: 'Actions',
32
+ onSelect: jest.fn(),
33
+ shortcut: '⌘I',
34
+ },
35
+ {
36
+ id: 'action-export',
37
+ label: 'Export Data',
38
+ group: 'Actions',
39
+ onSelect: jest.fn(),
40
+ },
41
+ ]
42
+
43
+ function makeOpts(overrides: Partial<Parameters<typeof useCommandPaletteSearch>[0]> = {}) {
44
+ return {
45
+ commands: COMMANDS,
46
+ onExecute: jest.fn(),
47
+ isOpen: true,
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ function makeKeyEvent(key: string): React.KeyboardEvent {
53
+ return { key, preventDefault: jest.fn() } as unknown as React.KeyboardEvent
54
+ }
55
+
56
+ describe('useCommandPaletteSearch', () => {
57
+ describe('initial state', () => {
58
+ it('returns empty query on open', () => {
59
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
60
+ expect(result.current.query).toBe('')
61
+ })
62
+
63
+ it('selects first item (index 0) on open', () => {
64
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
65
+ expect(result.current.selectedIndex).toBe(0)
66
+ })
67
+
68
+ it('exposes setQuery and onKeyDown functions', () => {
69
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
70
+ expect(typeof result.current.setQuery).toBe('function')
71
+ expect(typeof result.current.onKeyDown).toBe('function')
72
+ })
73
+ })
74
+
75
+ describe('filtering', () => {
76
+ it('shows all commands when query is empty', () => {
77
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
78
+ const allItems = result.current.filteredGroups.flatMap((g) => g.items)
79
+ expect(allItems).toHaveLength(COMMANDS.length)
80
+ })
81
+
82
+ it('narrows results by label match (case-insensitive)', () => {
83
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
84
+ act(() => { result.current.setQuery('invite') })
85
+ const items = result.current.filteredGroups.flatMap((g) => g.items)
86
+ expect(items).toHaveLength(1)
87
+ expect(items[0].id).toBe('action-invite')
88
+ })
89
+
90
+ it('matches on detail field', () => {
91
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
92
+ act(() => { result.current.setQuery('dashboard') })
93
+ const items = result.current.filteredGroups.flatMap((g) => g.items)
94
+ expect(items).toHaveLength(1)
95
+ expect(items[0].id).toBe('nav-home')
96
+ })
97
+
98
+ it('is case-insensitive', () => {
99
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
100
+ act(() => { result.current.setQuery('EXPORT') })
101
+ const items = result.current.filteredGroups.flatMap((g) => g.items)
102
+ expect(items).toHaveLength(1)
103
+ expect(items[0].id).toBe('action-export')
104
+ })
105
+
106
+ it('returns empty groups when no commands match', () => {
107
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
108
+ act(() => { result.current.setQuery('zzznomatch') })
109
+ expect(result.current.filteredGroups).toHaveLength(0)
110
+ })
111
+
112
+ it('resets selection to 0 when query changes', () => {
113
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
114
+ // Move selection down first
115
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
116
+ expect(result.current.selectedIndex).toBe(1)
117
+ // Now type to filter
118
+ act(() => { result.current.setQuery('Settings') })
119
+ expect(result.current.selectedIndex).toBe(0)
120
+ })
121
+ })
122
+
123
+ describe('grouping', () => {
124
+ it('groups items by their group field', () => {
125
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
126
+ const labels = result.current.filteredGroups.map((g) => g.label)
127
+ expect(labels).toContain('Navigation')
128
+ expect(labels).toContain('Actions')
129
+ })
130
+
131
+ it('places each item in the correct group', () => {
132
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
133
+ const nav = result.current.filteredGroups.find((g) => g.label === 'Navigation')!
134
+ expect(nav.items.map((i) => i.id)).toEqual(['nav-home', 'nav-settings'])
135
+ const actions = result.current.filteredGroups.find((g) => g.label === 'Actions')!
136
+ expect(actions.items.map((i) => i.id)).toEqual(['action-invite', 'action-export'])
137
+ })
138
+
139
+ it('preserves group insertion order', () => {
140
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
141
+ const labels = result.current.filteredGroups.map((g) => g.label)
142
+ expect(labels[0]).toBe('Navigation')
143
+ expect(labels[1]).toBe('Actions')
144
+ })
145
+
146
+ it('produces a single group when all matches share a group', () => {
147
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
148
+ act(() => { result.current.setQuery('Go to') })
149
+ expect(result.current.filteredGroups).toHaveLength(1)
150
+ expect(result.current.filteredGroups[0].label).toBe('Navigation')
151
+ })
152
+ })
153
+
154
+ describe('keyboard navigation — ArrowDown', () => {
155
+ it('moves selectedIndex down by one', () => {
156
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
157
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
158
+ expect(result.current.selectedIndex).toBe(1)
159
+ })
160
+
161
+ it('calls preventDefault on ArrowDown', () => {
162
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
163
+ const e = makeKeyEvent('ArrowDown')
164
+ act(() => { result.current.onKeyDown(e) })
165
+ expect(e.preventDefault).toHaveBeenCalled()
166
+ })
167
+
168
+ it('clamps at the last item', () => {
169
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
170
+ const lastIndex = COMMANDS.length - 1
171
+ // Jump to end then try to go further
172
+ for (let i = 0; i <= lastIndex + 2; i++) {
173
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
174
+ }
175
+ expect(result.current.selectedIndex).toBe(lastIndex)
176
+ })
177
+ })
178
+
179
+ describe('keyboard navigation — ArrowUp', () => {
180
+ it('moves selectedIndex up by one', () => {
181
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
182
+ // Go down first so we have room to go up
183
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
184
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
185
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
186
+ expect(result.current.selectedIndex).toBe(1)
187
+ })
188
+
189
+ it('calls preventDefault on ArrowUp', () => {
190
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
191
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
192
+ const e = makeKeyEvent('ArrowUp')
193
+ act(() => { result.current.onKeyDown(e) })
194
+ expect(e.preventDefault).toHaveBeenCalled()
195
+ })
196
+
197
+ it('clamps at index 0', () => {
198
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
199
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
200
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
201
+ expect(result.current.selectedIndex).toBe(0)
202
+ })
203
+ })
204
+
205
+ describe('keyboard navigation — Enter', () => {
206
+ it('calls onExecute with the currently selected item', () => {
207
+ const onExecute = jest.fn()
208
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
209
+ // selectedIndex is 0 → nav-home
210
+ const e = makeKeyEvent('Enter')
211
+ act(() => { result.current.onKeyDown(e) })
212
+ expect(onExecute).toHaveBeenCalledTimes(1)
213
+ expect(onExecute).toHaveBeenCalledWith(COMMANDS[0])
214
+ })
215
+
216
+ it('calls preventDefault on Enter', () => {
217
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
218
+ const e = makeKeyEvent('Enter')
219
+ act(() => { result.current.onKeyDown(e) })
220
+ expect(e.preventDefault).toHaveBeenCalled()
221
+ })
222
+
223
+ it('executes the correct item after navigating down', () => {
224
+ const onExecute = jest.fn()
225
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
226
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
227
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
228
+ act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) })
229
+ expect(onExecute).toHaveBeenCalledWith(COMMANDS[2])
230
+ })
231
+
232
+ it('does not call onExecute when filtered list is empty', () => {
233
+ const onExecute = jest.fn()
234
+ const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
235
+ act(() => { result.current.setQuery('zzznomatch') })
236
+ act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) })
237
+ expect(onExecute).not.toHaveBeenCalled()
238
+ })
239
+ })
240
+
241
+ describe('isOpen lifecycle', () => {
242
+ it('resets query when palette opens', () => {
243
+ const { result, rerender } = renderHook(
244
+ (props: Parameters<typeof useCommandPaletteSearch>[0]) =>
245
+ useCommandPaletteSearch(props),
246
+ { initialProps: makeOpts({ isOpen: true }) }
247
+ )
248
+ act(() => { result.current.setQuery('home') })
249
+ expect(result.current.query).toBe('home')
250
+
251
+ // Close then re-open
252
+ rerender(makeOpts({ isOpen: false }))
253
+ rerender(makeOpts({ isOpen: true }))
254
+ expect(result.current.query).toBe('')
255
+ })
256
+
257
+ it('resets selectedIndex when palette opens', () => {
258
+ const { result, rerender } = renderHook(
259
+ (props: Parameters<typeof useCommandPaletteSearch>[0]) =>
260
+ useCommandPaletteSearch(props),
261
+ { initialProps: makeOpts({ isOpen: true }) }
262
+ )
263
+ act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
264
+ expect(result.current.selectedIndex).toBe(1)
265
+
266
+ rerender(makeOpts({ isOpen: false }))
267
+ rerender(makeOpts({ isOpen: true }))
268
+ expect(result.current.selectedIndex).toBe(0)
269
+ })
270
+ })
271
+ })
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
4
+
5
+ interface CommandPaletteContextType {
6
+ isOpen: boolean
7
+ open: () => void
8
+ close: () => void
9
+ toggle: () => void
10
+ }
11
+
12
+ const CommandPaletteContext = createContext<CommandPaletteContextType | undefined>(undefined)
13
+
14
+ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
15
+ const [isOpen, setIsOpen] = useState(false)
16
+
17
+ const open = useCallback(() => setIsOpen(true), [])
18
+ const close = useCallback(() => setIsOpen(false), [])
19
+ const toggle = useCallback(() => setIsOpen(prev => !prev), [])
20
+
21
+ // Global keyboard shortcut: CMD+K / Ctrl+K
22
+ useEffect(() => {
23
+ const handleKeyDown = (e: KeyboardEvent) => {
24
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
25
+ e.preventDefault()
26
+ toggle()
27
+ }
28
+ if (e.key === 'Escape' && isOpen) {
29
+ e.preventDefault()
30
+ close()
31
+ }
32
+ }
33
+
34
+ document.addEventListener('keydown', handleKeyDown)
35
+ return () => document.removeEventListener('keydown', handleKeyDown)
36
+ }, [isOpen, toggle, close])
37
+
38
+ return (
39
+ <CommandPaletteContext.Provider value={{ isOpen, open, close, toggle }}>
40
+ {children}
41
+ </CommandPaletteContext.Provider>
42
+ )
43
+ }
44
+
45
+ export function useCommandPalette() {
46
+ const context = useContext(CommandPaletteContext)
47
+ if (!context) {
48
+ throw new Error('useCommandPalette must be used within a CommandPaletteProvider')
49
+ }
50
+ return context
51
+ }
@@ -0,0 +1,9 @@
1
+ export { CommandPalette } from './CommandPalette'
2
+ export type { CommandPaletteProps, SearchResult, QuickAction, NavigationItem } from './CommandPalette'
3
+ export { CommandPaletteProvider, useCommandPalette } from './command-palette-context'
4
+ export { useCommandPaletteSearch } from './useCommandPaletteSearch'
5
+ export type { CommandItem, CommandGroup as CommandGroupType, UseCommandPaletteOptions, UseCommandPaletteReturn } from './useCommandPaletteSearch'
6
+ export { CommandGroup } from './CommandGroup'
7
+ export type { CommandGroupProps } from './CommandGroup'
8
+ export { CommandResultItem } from './CommandResultItem'
9
+ export type { CommandResultItemProps } from './CommandResultItem'
@@ -0,0 +1,114 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useMemo } from 'react'
4
+
5
+ /** Unified command item for the palette */
6
+ export interface CommandItem {
7
+ id: string
8
+ icon?: React.ElementType
9
+ label: string
10
+ detail?: string
11
+ shortcut?: string
12
+ group: string
13
+ /** Navigate path or action callback */
14
+ onSelect: (() => void) | string
15
+ /** Optional color class for icon container */
16
+ colorClass?: string
17
+ }
18
+
19
+ export interface CommandGroup {
20
+ label: string
21
+ items: CommandItem[]
22
+ }
23
+
24
+ export interface UseCommandPaletteOptions {
25
+ commands: CommandItem[]
26
+ onExecute: (command: CommandItem) => void
27
+ isOpen: boolean
28
+ }
29
+
30
+ export interface UseCommandPaletteReturn {
31
+ query: string
32
+ setQuery: (q: string) => void
33
+ filteredGroups: CommandGroup[]
34
+ selectedIndex: number
35
+ onKeyDown: (e: React.KeyboardEvent) => void
36
+ }
37
+
38
+ export function useCommandPaletteSearch(opts: UseCommandPaletteOptions): UseCommandPaletteReturn {
39
+ const { commands, onExecute, isOpen } = opts
40
+ const [query, setQuery] = useState('')
41
+ const [selectedIndex, setSelectedIndex] = useState(0)
42
+
43
+ // Filter commands by query and group them
44
+ const filteredGroups = useMemo(() => {
45
+ const lowerQuery = query.toLowerCase()
46
+ const filtered = query
47
+ ? commands.filter(
48
+ (cmd) =>
49
+ cmd.label.toLowerCase().includes(lowerQuery) ||
50
+ (cmd.detail && cmd.detail.toLowerCase().includes(lowerQuery))
51
+ )
52
+ : commands
53
+
54
+ // Group by group label, preserving insertion order
55
+ const groupMap = new Map<string, CommandItem[]>()
56
+ for (const cmd of filtered) {
57
+ const existing = groupMap.get(cmd.group)
58
+ if (existing) {
59
+ existing.push(cmd)
60
+ } else {
61
+ groupMap.set(cmd.group, [cmd])
62
+ }
63
+ }
64
+
65
+ const groups: CommandGroup[] = []
66
+ for (const [label, items] of groupMap) {
67
+ groups.push({ label, items })
68
+ }
69
+ return groups
70
+ }, [commands, query])
71
+
72
+ // Flat list for keyboard nav
73
+ const flatItems = useMemo(
74
+ () => filteredGroups.flatMap((g) => g.items),
75
+ [filteredGroups]
76
+ )
77
+
78
+ // Reset state when palette opens/closes
79
+ useEffect(() => {
80
+ if (isOpen) {
81
+ setQuery('')
82
+ setSelectedIndex(0)
83
+ }
84
+ }, [isOpen])
85
+
86
+ // Reset selection when filtered results change
87
+ useEffect(() => {
88
+ setSelectedIndex(0)
89
+ }, [filteredGroups])
90
+
91
+ const onKeyDown = useCallback(
92
+ (e: React.KeyboardEvent) => {
93
+ if (e.key === 'ArrowDown') {
94
+ e.preventDefault()
95
+ setSelectedIndex((prev) => Math.min(prev + 1, flatItems.length - 1))
96
+ } else if (e.key === 'ArrowUp') {
97
+ e.preventDefault()
98
+ setSelectedIndex((prev) => Math.max(prev - 1, 0))
99
+ } else if (e.key === 'Enter' && flatItems[selectedIndex]) {
100
+ e.preventDefault()
101
+ onExecute(flatItems[selectedIndex])
102
+ }
103
+ },
104
+ [flatItems, selectedIndex, onExecute]
105
+ )
106
+
107
+ return {
108
+ query,
109
+ setQuery,
110
+ filteredGroups,
111
+ selectedIndex,
112
+ onKeyDown,
113
+ }
114
+ }