@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.
Files changed (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. 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
+ })