@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,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
|
+
}
|