@mdxui/terminal 2.0.0
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/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- package/src/types.ts +103 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal Sidebar Navigation Component Tests
|
|
3
|
+
*
|
|
4
|
+
* TDD RED Phase: Tests for the Sidebar navigation component.
|
|
5
|
+
* All tests should FAIL initially because the Sidebar renderers don't exist yet.
|
|
6
|
+
*
|
|
7
|
+
* The Sidebar component provides hierarchical navigation with:
|
|
8
|
+
* - Sections (grouped items)
|
|
9
|
+
* - Items (clickable navigation entries)
|
|
10
|
+
* - Collapse/expand state
|
|
11
|
+
* - Active state indication
|
|
12
|
+
* - Keyboard navigation (INTERACTIVE tier)
|
|
13
|
+
*
|
|
14
|
+
* This is part of the Universal Terminal UI 6-tier rendering system.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
17
|
+
import type { UINode, RenderTier, RenderContext, ThemeTokens } from '../../../core/types'
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Test Utilities
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const RENDER_TIERS: RenderTier[] = ['text', 'markdown', 'ascii', 'unicode', 'ansi', 'interactive']
|
|
24
|
+
|
|
25
|
+
function createTestTheme(): ThemeTokens {
|
|
26
|
+
return {
|
|
27
|
+
primary: '\x1b[34m',
|
|
28
|
+
secondary: '\x1b[36m',
|
|
29
|
+
muted: '\x1b[90m',
|
|
30
|
+
foreground: '\x1b[37m',
|
|
31
|
+
background: '\x1b[40m',
|
|
32
|
+
border: '\x1b[90m',
|
|
33
|
+
success: '\x1b[32m',
|
|
34
|
+
warning: '\x1b[33m',
|
|
35
|
+
error: '\x1b[31m',
|
|
36
|
+
info: '\x1b[34m',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createContext(tier: RenderTier): RenderContext {
|
|
41
|
+
return {
|
|
42
|
+
tier,
|
|
43
|
+
width: 80,
|
|
44
|
+
height: 24,
|
|
45
|
+
depth: 0,
|
|
46
|
+
theme: createTestTheme(),
|
|
47
|
+
interactive: tier === 'interactive',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Sidebar Node Types
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
interface SidebarItem {
|
|
56
|
+
id: string
|
|
57
|
+
label: string
|
|
58
|
+
icon?: string
|
|
59
|
+
path?: string
|
|
60
|
+
badge?: string | number
|
|
61
|
+
disabled?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SidebarSection {
|
|
65
|
+
id: string
|
|
66
|
+
title: string
|
|
67
|
+
collapsible?: boolean
|
|
68
|
+
collapsed?: boolean
|
|
69
|
+
items: SidebarItem[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface SidebarProps {
|
|
73
|
+
sections: SidebarSection[]
|
|
74
|
+
activeId?: string
|
|
75
|
+
collapsedIds?: string[]
|
|
76
|
+
onNavigate?: (id: string, path?: string) => void
|
|
77
|
+
onToggleCollapse?: (sectionId: string) => void
|
|
78
|
+
width?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createSidebarNode(props: SidebarProps): UINode {
|
|
82
|
+
return {
|
|
83
|
+
type: 'sidebar',
|
|
84
|
+
props,
|
|
85
|
+
children: props.sections.map((section) => ({
|
|
86
|
+
type: 'sidebar-section',
|
|
87
|
+
props: {
|
|
88
|
+
id: section.id,
|
|
89
|
+
title: section.title,
|
|
90
|
+
collapsible: section.collapsible,
|
|
91
|
+
collapsed: section.collapsed,
|
|
92
|
+
},
|
|
93
|
+
children: section.items.map((item) => ({
|
|
94
|
+
type: 'sidebar-item',
|
|
95
|
+
props: {
|
|
96
|
+
id: item.id,
|
|
97
|
+
label: item.label,
|
|
98
|
+
icon: item.icon,
|
|
99
|
+
path: item.path,
|
|
100
|
+
badge: item.badge,
|
|
101
|
+
disabled: item.disabled,
|
|
102
|
+
active: props.activeId === item.id,
|
|
103
|
+
},
|
|
104
|
+
})),
|
|
105
|
+
})),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Test Suite: Basic Sidebar Rendering
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
describe('Sidebar Component', () => {
|
|
114
|
+
describe('basic rendering', () => {
|
|
115
|
+
it('renders sidebar with sections and items', async () => {
|
|
116
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
117
|
+
|
|
118
|
+
const node = createSidebarNode({
|
|
119
|
+
sections: [
|
|
120
|
+
{
|
|
121
|
+
id: 'main',
|
|
122
|
+
title: 'Main Menu',
|
|
123
|
+
items: [
|
|
124
|
+
{ id: 'dashboard', label: 'Dashboard', icon: '\u2302' },
|
|
125
|
+
{ id: 'settings', label: 'Settings', icon: '\u2699' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const ctx = createContext('unicode')
|
|
132
|
+
const result = renderSidebar(node, ctx)
|
|
133
|
+
|
|
134
|
+
expect(result).toContain('Main Menu')
|
|
135
|
+
expect(result).toContain('Dashboard')
|
|
136
|
+
expect(result).toContain('Settings')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders multiple sections', async () => {
|
|
140
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
141
|
+
|
|
142
|
+
const node = createSidebarNode({
|
|
143
|
+
sections: [
|
|
144
|
+
{
|
|
145
|
+
id: 'main',
|
|
146
|
+
title: 'Main',
|
|
147
|
+
items: [{ id: 'home', label: 'Home' }],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'admin',
|
|
151
|
+
title: 'Administration',
|
|
152
|
+
items: [{ id: 'users', label: 'Users' }],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const ctx = createContext('unicode')
|
|
158
|
+
const result = renderSidebar(node, ctx)
|
|
159
|
+
|
|
160
|
+
expect(result).toContain('Main')
|
|
161
|
+
expect(result).toContain('Administration')
|
|
162
|
+
expect(result).toContain('Home')
|
|
163
|
+
expect(result).toContain('Users')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('renders sidebar with icons', async () => {
|
|
167
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
168
|
+
|
|
169
|
+
const node = createSidebarNode({
|
|
170
|
+
sections: [
|
|
171
|
+
{
|
|
172
|
+
id: 'nav',
|
|
173
|
+
title: 'Navigation',
|
|
174
|
+
items: [
|
|
175
|
+
{ id: 'home', label: 'Home', icon: '\u2302' },
|
|
176
|
+
{ id: 'search', label: 'Search', icon: '\u2315' },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const ctx = createContext('unicode')
|
|
183
|
+
const result = renderSidebar(node, ctx)
|
|
184
|
+
|
|
185
|
+
expect(result).toContain('\u2302')
|
|
186
|
+
expect(result).toContain('\u2315')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('renders sidebar with badges', async () => {
|
|
190
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
191
|
+
|
|
192
|
+
const node = createSidebarNode({
|
|
193
|
+
sections: [
|
|
194
|
+
{
|
|
195
|
+
id: 'main',
|
|
196
|
+
title: 'Main',
|
|
197
|
+
items: [
|
|
198
|
+
{ id: 'inbox', label: 'Inbox', badge: 5 },
|
|
199
|
+
{ id: 'drafts', label: 'Drafts', badge: 'NEW' },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const ctx = createContext('unicode')
|
|
206
|
+
const result = renderSidebar(node, ctx)
|
|
207
|
+
|
|
208
|
+
expect(result).toContain('5')
|
|
209
|
+
expect(result).toContain('NEW')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('returns empty string for empty sections', async () => {
|
|
213
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
214
|
+
|
|
215
|
+
const node = createSidebarNode({ sections: [] })
|
|
216
|
+
const ctx = createContext('unicode')
|
|
217
|
+
const result = renderSidebar(node, ctx)
|
|
218
|
+
|
|
219
|
+
expect(result).toBe('')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Active State Tests
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
describe('active state', () => {
|
|
228
|
+
it('highlights active item', async () => {
|
|
229
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
230
|
+
|
|
231
|
+
const node = createSidebarNode({
|
|
232
|
+
sections: [
|
|
233
|
+
{
|
|
234
|
+
id: 'main',
|
|
235
|
+
title: 'Main',
|
|
236
|
+
items: [
|
|
237
|
+
{ id: 'home', label: 'Home' },
|
|
238
|
+
{ id: 'about', label: 'About' },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
activeId: 'about',
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const ctx = createContext('ansi')
|
|
246
|
+
const result = renderSidebar(node, ctx)
|
|
247
|
+
|
|
248
|
+
// Active item should have special styling (ANSI codes)
|
|
249
|
+
expect(result).toContain('\x1b[')
|
|
250
|
+
expect(result).toContain('About')
|
|
251
|
+
|
|
252
|
+
// Active item should have different styling than inactive
|
|
253
|
+
// Could be bold, inverted, or colored
|
|
254
|
+
const aboutIndex = result.indexOf('About')
|
|
255
|
+
const homeIndex = result.indexOf('Home')
|
|
256
|
+
|
|
257
|
+
// The section of string around About should have more ANSI codes
|
|
258
|
+
const aboutSection = result.slice(Math.max(0, aboutIndex - 20), aboutIndex + 10)
|
|
259
|
+
expect(aboutSection).toMatch(/\x1b\[.*m/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('shows active indicator prefix in text tier', async () => {
|
|
263
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
264
|
+
|
|
265
|
+
const node = createSidebarNode({
|
|
266
|
+
sections: [
|
|
267
|
+
{
|
|
268
|
+
id: 'main',
|
|
269
|
+
title: 'Main',
|
|
270
|
+
items: [
|
|
271
|
+
{ id: 'home', label: 'Home' },
|
|
272
|
+
{ id: 'about', label: 'About' },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
activeId: 'about',
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const ctx = createContext('text')
|
|
280
|
+
const result = renderSidebar(node, ctx)
|
|
281
|
+
|
|
282
|
+
// Active item should have a visual indicator like > or *
|
|
283
|
+
expect(result).toMatch(/[>*\u25B6\u2192]\s*About/)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('shows active indicator with unicode arrow', async () => {
|
|
287
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
288
|
+
|
|
289
|
+
const node = createSidebarNode({
|
|
290
|
+
sections: [
|
|
291
|
+
{
|
|
292
|
+
id: 'main',
|
|
293
|
+
title: 'Main',
|
|
294
|
+
items: [{ id: 'active-item', label: 'Active Item' }],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
activeId: 'active-item',
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const ctx = createContext('unicode')
|
|
301
|
+
const result = renderSidebar(node, ctx)
|
|
302
|
+
|
|
303
|
+
// Unicode tier should use proper arrow characters
|
|
304
|
+
expect(result).toMatch(/[\u25B6\u2192\u25CF\u25BA]\s*Active Item/)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('only one item can be active at a time', async () => {
|
|
308
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
309
|
+
|
|
310
|
+
const node = createSidebarNode({
|
|
311
|
+
sections: [
|
|
312
|
+
{
|
|
313
|
+
id: 'main',
|
|
314
|
+
title: 'Main',
|
|
315
|
+
items: [
|
|
316
|
+
{ id: 'a', label: 'Item A' },
|
|
317
|
+
{ id: 'b', label: 'Item B' },
|
|
318
|
+
{ id: 'c', label: 'Item C' },
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
activeId: 'b',
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const ctx = createContext('text')
|
|
326
|
+
const result = renderSidebar(node, ctx)
|
|
327
|
+
|
|
328
|
+
// Count active indicators (should be exactly 1)
|
|
329
|
+
const lines = result.split('\n')
|
|
330
|
+
const activeLines = lines.filter((line) => line.match(/[>*\u25B6\u2192]\s*Item/))
|
|
331
|
+
expect(activeLines.length).toBe(1)
|
|
332
|
+
expect(activeLines[0]).toContain('Item B')
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// Collapse/Expand State Tests
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
describe('collapse/expand state', () => {
|
|
341
|
+
it('shows collapse indicator for collapsible sections', async () => {
|
|
342
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
343
|
+
|
|
344
|
+
const node = createSidebarNode({
|
|
345
|
+
sections: [
|
|
346
|
+
{
|
|
347
|
+
id: 'collapsible',
|
|
348
|
+
title: 'Collapsible Section',
|
|
349
|
+
collapsible: true,
|
|
350
|
+
collapsed: false,
|
|
351
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const ctx = createContext('unicode')
|
|
357
|
+
const result = renderSidebar(node, ctx)
|
|
358
|
+
|
|
359
|
+
// Should show expanded indicator (down arrow or minus)
|
|
360
|
+
expect(result).toMatch(/[\u25BC\u25BD\u25BE\u2212\u2796-]\s*Collapsible Section/)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('shows expand indicator for collapsed sections', async () => {
|
|
364
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
365
|
+
|
|
366
|
+
const node = createSidebarNode({
|
|
367
|
+
sections: [
|
|
368
|
+
{
|
|
369
|
+
id: 'collapsed',
|
|
370
|
+
title: 'Collapsed Section',
|
|
371
|
+
collapsible: true,
|
|
372
|
+
collapsed: true,
|
|
373
|
+
items: [{ id: 'hidden', label: 'Hidden Item' }],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const ctx = createContext('unicode')
|
|
379
|
+
const result = renderSidebar(node, ctx)
|
|
380
|
+
|
|
381
|
+
// Should show collapsed indicator (right arrow or plus)
|
|
382
|
+
expect(result).toMatch(/[\u25B6\u25B7\u25B8\u002B\u2795+]\s*Collapsed Section/)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('hides items in collapsed sections', async () => {
|
|
386
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
387
|
+
|
|
388
|
+
const node = createSidebarNode({
|
|
389
|
+
sections: [
|
|
390
|
+
{
|
|
391
|
+
id: 'collapsed',
|
|
392
|
+
title: 'Collapsed Section',
|
|
393
|
+
collapsible: true,
|
|
394
|
+
collapsed: true,
|
|
395
|
+
items: [
|
|
396
|
+
{ id: 'hidden1', label: 'Hidden Item 1' },
|
|
397
|
+
{ id: 'hidden2', label: 'Hidden Item 2' },
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const ctx = createContext('unicode')
|
|
404
|
+
const result = renderSidebar(node, ctx)
|
|
405
|
+
|
|
406
|
+
// Section title should be visible
|
|
407
|
+
expect(result).toContain('Collapsed Section')
|
|
408
|
+
|
|
409
|
+
// Items should be hidden
|
|
410
|
+
expect(result).not.toContain('Hidden Item 1')
|
|
411
|
+
expect(result).not.toContain('Hidden Item 2')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('shows items in expanded sections', async () => {
|
|
415
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
416
|
+
|
|
417
|
+
const node = createSidebarNode({
|
|
418
|
+
sections: [
|
|
419
|
+
{
|
|
420
|
+
id: 'expanded',
|
|
421
|
+
title: 'Expanded Section',
|
|
422
|
+
collapsible: true,
|
|
423
|
+
collapsed: false,
|
|
424
|
+
items: [
|
|
425
|
+
{ id: 'visible1', label: 'Visible Item 1' },
|
|
426
|
+
{ id: 'visible2', label: 'Visible Item 2' },
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const ctx = createContext('unicode')
|
|
433
|
+
const result = renderSidebar(node, ctx)
|
|
434
|
+
|
|
435
|
+
// All items should be visible
|
|
436
|
+
expect(result).toContain('Expanded Section')
|
|
437
|
+
expect(result).toContain('Visible Item 1')
|
|
438
|
+
expect(result).toContain('Visible Item 2')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('non-collapsible sections have no indicator', async () => {
|
|
442
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
443
|
+
|
|
444
|
+
const node = createSidebarNode({
|
|
445
|
+
sections: [
|
|
446
|
+
{
|
|
447
|
+
id: 'fixed',
|
|
448
|
+
title: 'Fixed Section',
|
|
449
|
+
collapsible: false,
|
|
450
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const ctx = createContext('unicode')
|
|
456
|
+
const result = renderSidebar(node, ctx)
|
|
457
|
+
|
|
458
|
+
// Should not have collapse/expand indicators before title
|
|
459
|
+
expect(result).not.toMatch(/[\u25BC\u25B6\u25BD\u25B7\u2212\u002B+-]\s*Fixed Section/)
|
|
460
|
+
expect(result).toContain('Fixed Section')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('ASCII tier uses simple +/- for collapse indicators', async () => {
|
|
464
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
465
|
+
|
|
466
|
+
const node = createSidebarNode({
|
|
467
|
+
sections: [
|
|
468
|
+
{
|
|
469
|
+
id: 'collapsed',
|
|
470
|
+
title: 'Collapsed',
|
|
471
|
+
collapsible: true,
|
|
472
|
+
collapsed: true,
|
|
473
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: 'expanded',
|
|
477
|
+
title: 'Expanded',
|
|
478
|
+
collapsible: true,
|
|
479
|
+
collapsed: false,
|
|
480
|
+
items: [{ id: 'item2', label: 'Item 2' }],
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
const ctx = createContext('ascii')
|
|
486
|
+
const result = renderSidebar(node, ctx)
|
|
487
|
+
|
|
488
|
+
// ASCII uses + for collapsed, - for expanded
|
|
489
|
+
expect(result).toMatch(/[+]\s*Collapsed/)
|
|
490
|
+
expect(result).toMatch(/[-]\s*Expanded/)
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ============================================================================
|
|
495
|
+
// Disabled State Tests
|
|
496
|
+
// ============================================================================
|
|
497
|
+
|
|
498
|
+
describe('disabled items', () => {
|
|
499
|
+
it('renders disabled items with muted styling', async () => {
|
|
500
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
501
|
+
|
|
502
|
+
const node = createSidebarNode({
|
|
503
|
+
sections: [
|
|
504
|
+
{
|
|
505
|
+
id: 'main',
|
|
506
|
+
title: 'Main',
|
|
507
|
+
items: [
|
|
508
|
+
{ id: 'enabled', label: 'Enabled' },
|
|
509
|
+
{ id: 'disabled', label: 'Disabled', disabled: true },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
const ctx = createContext('ansi')
|
|
516
|
+
const result = renderSidebar(node, ctx)
|
|
517
|
+
|
|
518
|
+
// Disabled should be present but with muted styling
|
|
519
|
+
expect(result).toContain('Disabled')
|
|
520
|
+
// Should use muted color code (90m for bright black/gray)
|
|
521
|
+
expect(result).toContain('\x1b[90m')
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('text tier shows disabled with parentheses or strikethrough notation', async () => {
|
|
525
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
526
|
+
|
|
527
|
+
const node = createSidebarNode({
|
|
528
|
+
sections: [
|
|
529
|
+
{
|
|
530
|
+
id: 'main',
|
|
531
|
+
title: 'Main',
|
|
532
|
+
items: [{ id: 'disabled', label: 'Disabled Item', disabled: true }],
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
const ctx = createContext('text')
|
|
538
|
+
const result = renderSidebar(node, ctx)
|
|
539
|
+
|
|
540
|
+
// Text tier should indicate disabled status somehow
|
|
541
|
+
// Common approaches: (disabled), [disabled], or ~Disabled Item~
|
|
542
|
+
expect(result).toMatch(/\(disabled\)|\[disabled\]|~Disabled Item~|Disabled Item\s*\*disabled\*/)
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
// ============================================================================
|
|
547
|
+
// Width and Layout Tests
|
|
548
|
+
// ============================================================================
|
|
549
|
+
|
|
550
|
+
describe('width and layout', () => {
|
|
551
|
+
it('respects specified width', async () => {
|
|
552
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
553
|
+
|
|
554
|
+
const node = createSidebarNode({
|
|
555
|
+
sections: [
|
|
556
|
+
{
|
|
557
|
+
id: 'main',
|
|
558
|
+
title: 'Main',
|
|
559
|
+
items: [{ id: 'item', label: 'Item with a very long label that should be truncated' }],
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
width: 20,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
const ctx = createContext('unicode')
|
|
566
|
+
const result = renderSidebar(node, ctx)
|
|
567
|
+
|
|
568
|
+
// Each line should not exceed width
|
|
569
|
+
const lines = result.split('\n')
|
|
570
|
+
for (const line of lines) {
|
|
571
|
+
// Account for ANSI codes in measurement
|
|
572
|
+
const stripped = line.replace(/\x1b\[[\d;]*m/g, '')
|
|
573
|
+
expect(stripped.length).toBeLessThanOrEqual(20)
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('truncates long labels with ellipsis', async () => {
|
|
578
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
579
|
+
|
|
580
|
+
const node = createSidebarNode({
|
|
581
|
+
sections: [
|
|
582
|
+
{
|
|
583
|
+
id: 'main',
|
|
584
|
+
title: 'Main',
|
|
585
|
+
items: [{ id: 'item', label: 'Very Long Item Label That Exceeds Width' }],
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
width: 15,
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const ctx = createContext('unicode')
|
|
592
|
+
const result = renderSidebar(node, ctx)
|
|
593
|
+
|
|
594
|
+
// Should contain ellipsis character
|
|
595
|
+
expect(result).toMatch(/\u2026|\.\.\./)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('indents items under sections', async () => {
|
|
599
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
600
|
+
|
|
601
|
+
const node = createSidebarNode({
|
|
602
|
+
sections: [
|
|
603
|
+
{
|
|
604
|
+
id: 'main',
|
|
605
|
+
title: 'Main Section',
|
|
606
|
+
items: [{ id: 'item', label: 'Child Item' }],
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
const ctx = createContext('text')
|
|
612
|
+
const result = renderSidebar(node, ctx)
|
|
613
|
+
|
|
614
|
+
const lines = result.split('\n')
|
|
615
|
+
const sectionLine = lines.find((l) => l.includes('Main Section'))
|
|
616
|
+
const itemLine = lines.find((l) => l.includes('Child Item'))
|
|
617
|
+
|
|
618
|
+
if (sectionLine && itemLine) {
|
|
619
|
+
// Item should be indented relative to section
|
|
620
|
+
const sectionIndent = sectionLine.search(/\S/)
|
|
621
|
+
const itemIndent = itemLine.search(/\S/)
|
|
622
|
+
expect(itemIndent).toBeGreaterThan(sectionIndent)
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// ============================================================================
|
|
628
|
+
// Tier-Specific Rendering Tests
|
|
629
|
+
// ============================================================================
|
|
630
|
+
|
|
631
|
+
describe('tier-specific rendering', () => {
|
|
632
|
+
const sampleNode = createSidebarNode({
|
|
633
|
+
sections: [
|
|
634
|
+
{
|
|
635
|
+
id: 'main',
|
|
636
|
+
title: 'Navigation',
|
|
637
|
+
collapsible: true,
|
|
638
|
+
collapsed: false,
|
|
639
|
+
items: [
|
|
640
|
+
{ id: 'home', label: 'Home', icon: '\u2302' },
|
|
641
|
+
{ id: 'settings', label: 'Settings', icon: '\u2699' },
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
activeId: 'home',
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('text tier renders plain text without special characters', async () => {
|
|
649
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
650
|
+
|
|
651
|
+
const ctx = createContext('text')
|
|
652
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
653
|
+
|
|
654
|
+
expect(result).toContain('Navigation')
|
|
655
|
+
expect(result).toContain('Home')
|
|
656
|
+
expect(result).toContain('Settings')
|
|
657
|
+
|
|
658
|
+
// Should not contain ANSI codes
|
|
659
|
+
expect(result).not.toContain('\x1b[')
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('markdown tier uses markdown formatting', async () => {
|
|
663
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
664
|
+
|
|
665
|
+
const ctx = createContext('markdown')
|
|
666
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
667
|
+
|
|
668
|
+
// Should use markdown formatting
|
|
669
|
+
// Headings, bold, or list markers
|
|
670
|
+
expect(result).toMatch(/[#*-]|\*\*|\[/)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('ascii tier uses ASCII box characters', async () => {
|
|
674
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
675
|
+
|
|
676
|
+
const ctx = createContext('ascii')
|
|
677
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
678
|
+
|
|
679
|
+
// May use ASCII art borders or simple dashes/pipes
|
|
680
|
+
expect(result).toMatch(/[|+\-=]/)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('unicode tier uses box drawing characters', async () => {
|
|
684
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
685
|
+
|
|
686
|
+
const ctx = createContext('unicode')
|
|
687
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
688
|
+
|
|
689
|
+
// Unicode box drawing characters for borders/separators
|
|
690
|
+
expect(result).toMatch(/[\u2500-\u257F\u25A0-\u25FF]/)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('ansi tier includes color codes', async () => {
|
|
694
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
695
|
+
|
|
696
|
+
const ctx = createContext('ansi')
|
|
697
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
698
|
+
|
|
699
|
+
// Should contain ANSI escape sequences
|
|
700
|
+
expect(result).toContain('\x1b[')
|
|
701
|
+
expect(result).toContain('m')
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('interactive tier includes focus indicators', async () => {
|
|
705
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
706
|
+
|
|
707
|
+
const ctx = createContext('interactive')
|
|
708
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
709
|
+
|
|
710
|
+
// Interactive tier should have cursor/focus indicators
|
|
711
|
+
expect(result).toContain('\x1b[')
|
|
712
|
+
// May include inverse video for focused item
|
|
713
|
+
expect(result).toMatch(/\x1b\[7m|\x1b\[4m/)
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
// ============================================================================
|
|
718
|
+
// Keyboard Navigation Tests (INTERACTIVE Tier)
|
|
719
|
+
// ============================================================================
|
|
720
|
+
|
|
721
|
+
describe('keyboard navigation (interactive tier)', () => {
|
|
722
|
+
it('provides keyboard bindings for navigation', async () => {
|
|
723
|
+
const { getSidebarKeyBindings } = await import('../../../renderers/sidebar')
|
|
724
|
+
|
|
725
|
+
const bindings = getSidebarKeyBindings()
|
|
726
|
+
|
|
727
|
+
expect(bindings.j).toBe('move-down')
|
|
728
|
+
expect(bindings.k).toBe('move-up')
|
|
729
|
+
expect(bindings.enter).toBe('select')
|
|
730
|
+
expect(bindings.space).toBe('toggle-collapse')
|
|
731
|
+
expect(bindings.h).toBe('collapse')
|
|
732
|
+
expect(bindings.l).toBe('expand')
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('j/k moves focus between items', async () => {
|
|
736
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
737
|
+
|
|
738
|
+
const state = createSidebarState({
|
|
739
|
+
sections: [
|
|
740
|
+
{
|
|
741
|
+
id: 'main',
|
|
742
|
+
title: 'Main',
|
|
743
|
+
items: [
|
|
744
|
+
{ id: 'item1', label: 'Item 1' },
|
|
745
|
+
{ id: 'item2', label: 'Item 2' },
|
|
746
|
+
{ id: 'item3', label: 'Item 3' },
|
|
747
|
+
],
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
focusedId: 'item1',
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
// Move down with j
|
|
754
|
+
const afterJ = handleSidebarKey(state, 'j')
|
|
755
|
+
expect(afterJ.focusedId).toBe('item2')
|
|
756
|
+
|
|
757
|
+
// Move down again
|
|
758
|
+
const afterJ2 = handleSidebarKey(afterJ, 'j')
|
|
759
|
+
expect(afterJ2.focusedId).toBe('item3')
|
|
760
|
+
|
|
761
|
+
// Move up with k
|
|
762
|
+
const afterK = handleSidebarKey(afterJ2, 'k')
|
|
763
|
+
expect(afterK.focusedId).toBe('item2')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('enter selects the focused item', async () => {
|
|
767
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
768
|
+
|
|
769
|
+
const onNavigate = vi.fn()
|
|
770
|
+
|
|
771
|
+
const state = createSidebarState({
|
|
772
|
+
sections: [
|
|
773
|
+
{
|
|
774
|
+
id: 'main',
|
|
775
|
+
title: 'Main',
|
|
776
|
+
items: [
|
|
777
|
+
{ id: 'item1', label: 'Item 1', path: '/item1' },
|
|
778
|
+
{ id: 'item2', label: 'Item 2', path: '/item2' },
|
|
779
|
+
],
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
focusedId: 'item2',
|
|
783
|
+
onNavigate,
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
handleSidebarKey(state, 'enter')
|
|
787
|
+
|
|
788
|
+
expect(onNavigate).toHaveBeenCalledWith('item2', '/item2')
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('space toggles collapse on section', async () => {
|
|
792
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
793
|
+
|
|
794
|
+
const onToggleCollapse = vi.fn()
|
|
795
|
+
|
|
796
|
+
const state = createSidebarState({
|
|
797
|
+
sections: [
|
|
798
|
+
{
|
|
799
|
+
id: 'collapsible',
|
|
800
|
+
title: 'Collapsible',
|
|
801
|
+
collapsible: true,
|
|
802
|
+
collapsed: false,
|
|
803
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
focusedId: 'collapsible', // Focused on section header
|
|
807
|
+
onToggleCollapse,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
handleSidebarKey(state, 'space')
|
|
811
|
+
|
|
812
|
+
expect(onToggleCollapse).toHaveBeenCalledWith('collapsible')
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
it('h collapses a section', async () => {
|
|
816
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
817
|
+
|
|
818
|
+
const onToggleCollapse = vi.fn()
|
|
819
|
+
|
|
820
|
+
const state = createSidebarState({
|
|
821
|
+
sections: [
|
|
822
|
+
{
|
|
823
|
+
id: 'section',
|
|
824
|
+
title: 'Section',
|
|
825
|
+
collapsible: true,
|
|
826
|
+
collapsed: false,
|
|
827
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
focusedId: 'item', // Focused on item in section
|
|
831
|
+
onToggleCollapse,
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
// h should collapse parent section
|
|
835
|
+
handleSidebarKey(state, 'h')
|
|
836
|
+
|
|
837
|
+
expect(onToggleCollapse).toHaveBeenCalledWith('section')
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('l expands a collapsed section', async () => {
|
|
841
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
842
|
+
|
|
843
|
+
const onToggleCollapse = vi.fn()
|
|
844
|
+
|
|
845
|
+
const state = createSidebarState({
|
|
846
|
+
sections: [
|
|
847
|
+
{
|
|
848
|
+
id: 'section',
|
|
849
|
+
title: 'Section',
|
|
850
|
+
collapsible: true,
|
|
851
|
+
collapsed: true,
|
|
852
|
+
items: [{ id: 'item', label: 'Item' }],
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
focusedId: 'section', // Focused on collapsed section
|
|
856
|
+
onToggleCollapse,
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
// l should expand section
|
|
860
|
+
handleSidebarKey(state, 'l')
|
|
861
|
+
|
|
862
|
+
expect(onToggleCollapse).toHaveBeenCalledWith('section')
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
it('skips disabled items during navigation', async () => {
|
|
866
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
867
|
+
|
|
868
|
+
const state = createSidebarState({
|
|
869
|
+
sections: [
|
|
870
|
+
{
|
|
871
|
+
id: 'main',
|
|
872
|
+
title: 'Main',
|
|
873
|
+
items: [
|
|
874
|
+
{ id: 'item1', label: 'Item 1' },
|
|
875
|
+
{ id: 'item2', label: 'Item 2', disabled: true },
|
|
876
|
+
{ id: 'item3', label: 'Item 3' },
|
|
877
|
+
],
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
focusedId: 'item1',
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
// Move down should skip disabled item2
|
|
884
|
+
const afterJ = handleSidebarKey(state, 'j')
|
|
885
|
+
expect(afterJ.focusedId).toBe('item3')
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('wraps focus at boundaries when wrap enabled', async () => {
|
|
889
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
890
|
+
|
|
891
|
+
const state = createSidebarState({
|
|
892
|
+
sections: [
|
|
893
|
+
{
|
|
894
|
+
id: 'main',
|
|
895
|
+
title: 'Main',
|
|
896
|
+
items: [
|
|
897
|
+
{ id: 'item1', label: 'Item 1' },
|
|
898
|
+
{ id: 'item2', label: 'Item 2' },
|
|
899
|
+
],
|
|
900
|
+
},
|
|
901
|
+
],
|
|
902
|
+
focusedId: 'item2',
|
|
903
|
+
wrapNavigation: true,
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
// Move down from last should wrap to first
|
|
907
|
+
const afterJ = handleSidebarKey(state, 'j')
|
|
908
|
+
expect(afterJ.focusedId).toBe('item1')
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
it('stops at boundaries when wrap disabled', async () => {
|
|
912
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
913
|
+
|
|
914
|
+
const state = createSidebarState({
|
|
915
|
+
sections: [
|
|
916
|
+
{
|
|
917
|
+
id: 'main',
|
|
918
|
+
title: 'Main',
|
|
919
|
+
items: [
|
|
920
|
+
{ id: 'item1', label: 'Item 1' },
|
|
921
|
+
{ id: 'item2', label: 'Item 2' },
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
focusedId: 'item2',
|
|
926
|
+
wrapNavigation: false,
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
// Move down from last should stay at last
|
|
930
|
+
const afterJ = handleSidebarKey(state, 'j')
|
|
931
|
+
expect(afterJ.focusedId).toBe('item2')
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
it('g+g moves to first item', async () => {
|
|
935
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
936
|
+
|
|
937
|
+
const state = createSidebarState({
|
|
938
|
+
sections: [
|
|
939
|
+
{
|
|
940
|
+
id: 'main',
|
|
941
|
+
title: 'Main',
|
|
942
|
+
items: [
|
|
943
|
+
{ id: 'item1', label: 'Item 1' },
|
|
944
|
+
{ id: 'item2', label: 'Item 2' },
|
|
945
|
+
{ id: 'item3', label: 'Item 3' },
|
|
946
|
+
],
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
focusedId: 'item3',
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
// gg sequence (vim-style go to top)
|
|
953
|
+
const afterG = handleSidebarKey(state, 'g')
|
|
954
|
+
const afterGG = handleSidebarKey(afterG, 'g')
|
|
955
|
+
expect(afterGG.focusedId).toBe('item1')
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
it('G moves to last item', async () => {
|
|
959
|
+
const { createSidebarState, handleSidebarKey } = await import('../../../renderers/sidebar')
|
|
960
|
+
|
|
961
|
+
const state = createSidebarState({
|
|
962
|
+
sections: [
|
|
963
|
+
{
|
|
964
|
+
id: 'main',
|
|
965
|
+
title: 'Main',
|
|
966
|
+
items: [
|
|
967
|
+
{ id: 'item1', label: 'Item 1' },
|
|
968
|
+
{ id: 'item2', label: 'Item 2' },
|
|
969
|
+
{ id: 'item3', label: 'Item 3' },
|
|
970
|
+
],
|
|
971
|
+
},
|
|
972
|
+
],
|
|
973
|
+
focusedId: 'item1',
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// G (shift+g) moves to last
|
|
977
|
+
const afterG = handleSidebarKey(state, 'G')
|
|
978
|
+
expect(afterG.focusedId).toBe('item3')
|
|
979
|
+
})
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
// ============================================================================
|
|
983
|
+
// All Tiers Rendering Tests
|
|
984
|
+
// ============================================================================
|
|
985
|
+
|
|
986
|
+
describe('renders across all tiers', () => {
|
|
987
|
+
const sampleNode = createSidebarNode({
|
|
988
|
+
sections: [
|
|
989
|
+
{
|
|
990
|
+
id: 'nav',
|
|
991
|
+
title: 'Navigation',
|
|
992
|
+
items: [
|
|
993
|
+
{ id: 'home', label: 'Home' },
|
|
994
|
+
{ id: 'about', label: 'About' },
|
|
995
|
+
],
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
activeId: 'home',
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
RENDER_TIERS.forEach((tier) => {
|
|
1002
|
+
it(`renders on ${tier} tier`, async () => {
|
|
1003
|
+
const { renderSidebar } = await import('../../../renderers/sidebar')
|
|
1004
|
+
|
|
1005
|
+
const ctx = createContext(tier)
|
|
1006
|
+
const result = renderSidebar(sampleNode, ctx)
|
|
1007
|
+
|
|
1008
|
+
// Should produce some output
|
|
1009
|
+
expect(result.length).toBeGreaterThan(0)
|
|
1010
|
+
|
|
1011
|
+
// Should contain content
|
|
1012
|
+
expect(result).toContain('Navigation')
|
|
1013
|
+
expect(result).toContain('Home')
|
|
1014
|
+
expect(result).toContain('About')
|
|
1015
|
+
})
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
})
|