@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,995 @@
1
+ /**
2
+ * @mdxui/terminal Tabs Navigation Component Tests
3
+ *
4
+ * TDD RED Phase: Tests for the Tabs navigation component.
5
+ * All tests should FAIL initially because the Tabs renderers don't exist yet.
6
+ *
7
+ * The Tabs component provides tabbed navigation with:
8
+ * - Tab list (horizontal row of tabs)
9
+ * - Active tab indication
10
+ * - Content switching based on active tab
11
+ * - Keyboard navigation (arrow keys, home/end)
12
+ * - Disabled tab support
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
+ // Tab Types
53
+ // ============================================================================
54
+
55
+ interface Tab {
56
+ id: string
57
+ label: string
58
+ icon?: string
59
+ badge?: string | number
60
+ disabled?: boolean
61
+ content?: string | UINode
62
+ }
63
+
64
+ interface TabsProps {
65
+ tabs: Tab[]
66
+ activeId: string
67
+ variant?: 'default' | 'pills' | 'underline'
68
+ orientation?: 'horizontal' | 'vertical'
69
+ onTabChange?: (tabId: string) => void
70
+ }
71
+
72
+ function createTabsNode(props: TabsProps): UINode {
73
+ return {
74
+ type: 'tabs',
75
+ props: {
76
+ activeId: props.activeId,
77
+ variant: props.variant || 'default',
78
+ orientation: props.orientation || 'horizontal',
79
+ },
80
+ children: [
81
+ {
82
+ type: 'tab-list',
83
+ props: {},
84
+ children: props.tabs.map((tab) => ({
85
+ type: 'tab',
86
+ props: {
87
+ id: tab.id,
88
+ label: tab.label,
89
+ icon: tab.icon,
90
+ badge: tab.badge,
91
+ disabled: tab.disabled,
92
+ active: props.activeId === tab.id,
93
+ },
94
+ })),
95
+ },
96
+ ...props.tabs.map((tab) => ({
97
+ type: 'tab-panel',
98
+ props: {
99
+ tabId: tab.id,
100
+ active: props.activeId === tab.id,
101
+ },
102
+ children: typeof tab.content === 'string'
103
+ ? [{ type: 'text', props: { content: tab.content } }]
104
+ : tab.content ? [tab.content] : [],
105
+ })),
106
+ ],
107
+ }
108
+ }
109
+
110
+ // ============================================================================
111
+ // Test Suite: Basic Tab Rendering
112
+ // ============================================================================
113
+
114
+ describe('Tabs Component', () => {
115
+ describe('basic rendering', () => {
116
+ it('renders tabs with labels', async () => {
117
+ const { renderTabs } = await import('../../../renderers/tabs')
118
+
119
+ const node = createTabsNode({
120
+ tabs: [
121
+ { id: 'tab1', label: 'First Tab' },
122
+ { id: 'tab2', label: 'Second Tab' },
123
+ { id: 'tab3', label: 'Third Tab' },
124
+ ],
125
+ activeId: 'tab1',
126
+ })
127
+
128
+ const ctx = createContext('unicode')
129
+ const result = renderTabs(node, ctx)
130
+
131
+ expect(result).toContain('First Tab')
132
+ expect(result).toContain('Second Tab')
133
+ expect(result).toContain('Third Tab')
134
+ })
135
+
136
+ it('renders single tab', async () => {
137
+ const { renderTabs } = await import('../../../renderers/tabs')
138
+
139
+ const node = createTabsNode({
140
+ tabs: [{ id: 'only', label: 'Only Tab' }],
141
+ activeId: 'only',
142
+ })
143
+
144
+ const ctx = createContext('unicode')
145
+ const result = renderTabs(node, ctx)
146
+
147
+ expect(result).toContain('Only Tab')
148
+ })
149
+
150
+ it('returns empty string for no tabs', async () => {
151
+ const { renderTabs } = await import('../../../renderers/tabs')
152
+
153
+ const node = createTabsNode({
154
+ tabs: [],
155
+ activeId: '',
156
+ })
157
+
158
+ const ctx = createContext('unicode')
159
+ const result = renderTabs(node, ctx)
160
+
161
+ expect(result).toBe('')
162
+ })
163
+
164
+ it('renders tabs with icons', async () => {
165
+ const { renderTabs } = await import('../../../renderers/tabs')
166
+
167
+ const node = createTabsNode({
168
+ tabs: [
169
+ { id: 'home', label: 'Home', icon: '\u2302' },
170
+ { id: 'settings', label: 'Settings', icon: '\u2699' },
171
+ ],
172
+ activeId: 'home',
173
+ })
174
+
175
+ const ctx = createContext('unicode')
176
+ const result = renderTabs(node, ctx)
177
+
178
+ expect(result).toContain('\u2302')
179
+ expect(result).toContain('\u2699')
180
+ })
181
+
182
+ it('renders tabs with badges', async () => {
183
+ const { renderTabs } = await import('../../../renderers/tabs')
184
+
185
+ const node = createTabsNode({
186
+ tabs: [
187
+ { id: 'inbox', label: 'Inbox', badge: 5 },
188
+ { id: 'drafts', label: 'Drafts', badge: 'NEW' },
189
+ ],
190
+ activeId: 'inbox',
191
+ })
192
+
193
+ const ctx = createContext('unicode')
194
+ const result = renderTabs(node, ctx)
195
+
196
+ expect(result).toContain('5')
197
+ expect(result).toContain('NEW')
198
+ })
199
+ })
200
+
201
+ // ============================================================================
202
+ // Active Tab Tests
203
+ // ============================================================================
204
+
205
+ describe('active tab indication', () => {
206
+ it('highlights active tab', async () => {
207
+ const { renderTabs } = await import('../../../renderers/tabs')
208
+
209
+ const node = createTabsNode({
210
+ tabs: [
211
+ { id: 'tab1', label: 'Tab One' },
212
+ { id: 'tab2', label: 'Tab Two' },
213
+ ],
214
+ activeId: 'tab2',
215
+ })
216
+
217
+ const ctx = createContext('ansi')
218
+ const result = renderTabs(node, ctx)
219
+
220
+ // Active tab should have special styling
221
+ expect(result).toContain('Tab Two')
222
+ expect(result).toContain('\x1b[')
223
+
224
+ // Active tab should be styled differently (bold, inverse, or colored)
225
+ const tabTwoIndex = result.indexOf('Tab Two')
226
+ const beforeTabTwo = result.slice(Math.max(0, tabTwoIndex - 20), tabTwoIndex)
227
+ expect(beforeTabTwo).toMatch(/\x1b\[.*m/)
228
+ })
229
+
230
+ it('text tier shows active with brackets or marker', async () => {
231
+ const { renderTabs } = await import('../../../renderers/tabs')
232
+
233
+ const node = createTabsNode({
234
+ tabs: [
235
+ { id: 'tab1', label: 'Tab One' },
236
+ { id: 'tab2', label: 'Tab Two' },
237
+ ],
238
+ activeId: 'tab2',
239
+ })
240
+
241
+ const ctx = createContext('text')
242
+ const result = renderTabs(node, ctx)
243
+
244
+ // Active tab should have visual indicator
245
+ expect(result).toMatch(/\[Tab Two\]|\*Tab Two\*|>Tab Two|Tab Two</)
246
+ })
247
+
248
+ it('only one tab can be active', async () => {
249
+ const { renderTabs } = await import('../../../renderers/tabs')
250
+
251
+ const node = createTabsNode({
252
+ tabs: [
253
+ { id: 'a', label: 'Tab A' },
254
+ { id: 'b', label: 'Tab B' },
255
+ { id: 'c', label: 'Tab C' },
256
+ ],
257
+ activeId: 'b',
258
+ })
259
+
260
+ const ctx = createContext('text')
261
+ const result = renderTabs(node, ctx)
262
+
263
+ // Count active indicators (should be exactly 1)
264
+ const activeCount = (result.match(/\[Tab .\]|\*Tab .\*/g) || []).length
265
+ expect(activeCount).toBe(1)
266
+ })
267
+
268
+ it('underline variant shows underline on active tab', async () => {
269
+ const { renderTabs } = await import('../../../renderers/tabs')
270
+
271
+ const node = createTabsNode({
272
+ tabs: [
273
+ { id: 'tab1', label: 'Tab One' },
274
+ { id: 'tab2', label: 'Tab Two' },
275
+ ],
276
+ activeId: 'tab1',
277
+ variant: 'underline',
278
+ })
279
+
280
+ const ctx = createContext('unicode')
281
+ const result = renderTabs(node, ctx)
282
+
283
+ // Underline variant should show underline character under active tab
284
+ expect(result).toMatch(/\u2500{3,}|\u2581{3,}|_{3,}|-{3,}/)
285
+ })
286
+
287
+ it('pills variant shows pill styling on active tab', async () => {
288
+ const { renderTabs } = await import('../../../renderers/tabs')
289
+
290
+ const node = createTabsNode({
291
+ tabs: [
292
+ { id: 'tab1', label: 'Tab One' },
293
+ { id: 'tab2', label: 'Tab Two' },
294
+ ],
295
+ activeId: 'tab1',
296
+ variant: 'pills',
297
+ })
298
+
299
+ const ctx = createContext('unicode')
300
+ const result = renderTabs(node, ctx)
301
+
302
+ // Pills variant uses rounded border or background
303
+ expect(result).toMatch(/[\u256D\u256E\u256F\u2570\u2500\u2502]|\(\s*Tab One\s*\)/)
304
+ })
305
+ })
306
+
307
+ // ============================================================================
308
+ // Tab Panel / Content Switching Tests
309
+ // ============================================================================
310
+
311
+ describe('content switching', () => {
312
+ it('shows content for active tab', async () => {
313
+ const { renderTabs } = await import('../../../renderers/tabs')
314
+
315
+ const node = createTabsNode({
316
+ tabs: [
317
+ { id: 'tab1', label: 'Tab One', content: 'Content for tab one' },
318
+ { id: 'tab2', label: 'Tab Two', content: 'Content for tab two' },
319
+ ],
320
+ activeId: 'tab1',
321
+ })
322
+
323
+ const ctx = createContext('unicode')
324
+ const result = renderTabs(node, ctx)
325
+
326
+ expect(result).toContain('Content for tab one')
327
+ })
328
+
329
+ it('hides content for inactive tabs', async () => {
330
+ const { renderTabs } = await import('../../../renderers/tabs')
331
+
332
+ const node = createTabsNode({
333
+ tabs: [
334
+ { id: 'tab1', label: 'Tab One', content: 'Content for tab one' },
335
+ { id: 'tab2', label: 'Tab Two', content: 'Content for tab two' },
336
+ ],
337
+ activeId: 'tab1',
338
+ })
339
+
340
+ const ctx = createContext('unicode')
341
+ const result = renderTabs(node, ctx)
342
+
343
+ expect(result).toContain('Content for tab one')
344
+ expect(result).not.toContain('Content for tab two')
345
+ })
346
+
347
+ it('switches content when active tab changes', async () => {
348
+ const { renderTabs } = await import('../../../renderers/tabs')
349
+
350
+ const tabs = [
351
+ { id: 'tab1', label: 'Tab One', content: 'First content' },
352
+ { id: 'tab2', label: 'Tab Two', content: 'Second content' },
353
+ ]
354
+
355
+ const ctx = createContext('unicode')
356
+
357
+ // First render with tab1 active
358
+ const node1 = createTabsNode({ tabs, activeId: 'tab1' })
359
+ const result1 = renderTabs(node1, ctx)
360
+ expect(result1).toContain('First content')
361
+ expect(result1).not.toContain('Second content')
362
+
363
+ // Second render with tab2 active
364
+ const node2 = createTabsNode({ tabs, activeId: 'tab2' })
365
+ const result2 = renderTabs(node2, ctx)
366
+ expect(result2).not.toContain('First content')
367
+ expect(result2).toContain('Second content')
368
+ })
369
+
370
+ it('renders UINode content', async () => {
371
+ const { renderTabs } = await import('../../../renderers/tabs')
372
+
373
+ const complexContent: UINode = {
374
+ type: 'box',
375
+ props: { border: 'single' },
376
+ children: [{ type: 'text', props: { content: 'Nested content' } }],
377
+ }
378
+
379
+ const node = createTabsNode({
380
+ tabs: [{ id: 'tab1', label: 'Tab One', content: complexContent }],
381
+ activeId: 'tab1',
382
+ })
383
+
384
+ const ctx = createContext('unicode')
385
+ const result = renderTabs(node, ctx)
386
+
387
+ expect(result).toContain('Nested content')
388
+ })
389
+
390
+ it('handles tabs with no content', async () => {
391
+ const { renderTabs } = await import('../../../renderers/tabs')
392
+
393
+ const node = createTabsNode({
394
+ tabs: [
395
+ { id: 'tab1', label: 'Tab One' },
396
+ { id: 'tab2', label: 'Tab Two' },
397
+ ],
398
+ activeId: 'tab1',
399
+ })
400
+
401
+ const ctx = createContext('unicode')
402
+ const result = renderTabs(node, ctx)
403
+
404
+ // Should render tabs without crashing
405
+ expect(result).toContain('Tab One')
406
+ expect(result).toContain('Tab Two')
407
+ })
408
+ })
409
+
410
+ // ============================================================================
411
+ // Disabled Tab Tests
412
+ // ============================================================================
413
+
414
+ describe('disabled tabs', () => {
415
+ it('renders disabled tabs with muted styling', async () => {
416
+ const { renderTabs } = await import('../../../renderers/tabs')
417
+
418
+ const node = createTabsNode({
419
+ tabs: [
420
+ { id: 'enabled', label: 'Enabled' },
421
+ { id: 'disabled', label: 'Disabled', disabled: true },
422
+ ],
423
+ activeId: 'enabled',
424
+ })
425
+
426
+ const ctx = createContext('ansi')
427
+ const result = renderTabs(node, ctx)
428
+
429
+ // Disabled should use muted color
430
+ expect(result).toContain('Disabled')
431
+ expect(result).toContain('\x1b[90m') // Muted/gray color
432
+ })
433
+
434
+ it('text tier shows disabled indicator', async () => {
435
+ const { renderTabs } = await import('../../../renderers/tabs')
436
+
437
+ const node = createTabsNode({
438
+ tabs: [
439
+ { id: 'enabled', label: 'Enabled' },
440
+ { id: 'disabled', label: 'Disabled', disabled: true },
441
+ ],
442
+ activeId: 'enabled',
443
+ })
444
+
445
+ const ctx = createContext('text')
446
+ const result = renderTabs(node, ctx)
447
+
448
+ // Disabled should have visual indicator
449
+ expect(result).toMatch(/\(disabled\)|\[disabled\]|~Disabled~|Disabled\s*\*/)
450
+ })
451
+
452
+ it('disabled tab cannot be made active via keyboard', async () => {
453
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
454
+
455
+ const state = createTabsState({
456
+ tabs: [
457
+ { id: 'tab1', label: 'Tab 1' },
458
+ { id: 'disabled', label: 'Disabled', disabled: true },
459
+ { id: 'tab3', label: 'Tab 3' },
460
+ ],
461
+ activeId: 'tab1',
462
+ focusedId: 'tab1',
463
+ })
464
+
465
+ // Move right should skip disabled tab
466
+ const afterRight = handleTabsKey(state, 'right')
467
+ expect(afterRight.focusedId).toBe('tab3')
468
+ })
469
+ })
470
+
471
+ // ============================================================================
472
+ // Tab Orientation Tests
473
+ // ============================================================================
474
+
475
+ describe('orientation', () => {
476
+ it('horizontal tabs render in a row', async () => {
477
+ const { renderTabs } = await import('../../../renderers/tabs')
478
+
479
+ const node = createTabsNode({
480
+ tabs: [
481
+ { id: 'tab1', label: 'Tab One' },
482
+ { id: 'tab2', label: 'Tab Two' },
483
+ ],
484
+ activeId: 'tab1',
485
+ orientation: 'horizontal',
486
+ })
487
+
488
+ const ctx = createContext('unicode')
489
+ const result = renderTabs(node, ctx)
490
+
491
+ // Tabs should be on same line or separated horizontally
492
+ const lines = result.split('\n')
493
+ const tabLine = lines.find((l) => l.includes('Tab One') && l.includes('Tab Two'))
494
+ expect(tabLine).toBeDefined()
495
+ })
496
+
497
+ it('vertical tabs render in a column', async () => {
498
+ const { renderTabs } = await import('../../../renderers/tabs')
499
+
500
+ const node = createTabsNode({
501
+ tabs: [
502
+ { id: 'tab1', label: 'Tab One' },
503
+ { id: 'tab2', label: 'Tab Two' },
504
+ ],
505
+ activeId: 'tab1',
506
+ orientation: 'vertical',
507
+ })
508
+
509
+ const ctx = createContext('unicode')
510
+ const result = renderTabs(node, ctx)
511
+
512
+ // Tabs should be on different lines
513
+ const lines = result.split('\n')
514
+ const tabOneLine = lines.findIndex((l) => l.includes('Tab One'))
515
+ const tabTwoLine = lines.findIndex((l) => l.includes('Tab Two'))
516
+
517
+ expect(tabOneLine).toBeGreaterThanOrEqual(0)
518
+ expect(tabTwoLine).toBeGreaterThanOrEqual(0)
519
+ expect(tabOneLine).not.toBe(tabTwoLine)
520
+ })
521
+
522
+ it('vertical orientation uses j/k for navigation', async () => {
523
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
524
+
525
+ const state = createTabsState({
526
+ tabs: [
527
+ { id: 'tab1', label: 'Tab 1' },
528
+ { id: 'tab2', label: 'Tab 2' },
529
+ ],
530
+ activeId: 'tab1',
531
+ focusedId: 'tab1',
532
+ orientation: 'vertical',
533
+ })
534
+
535
+ // j should move down
536
+ const afterJ = handleTabsKey(state, 'j')
537
+ expect(afterJ.focusedId).toBe('tab2')
538
+
539
+ // k should move up
540
+ const afterK = handleTabsKey(afterJ, 'k')
541
+ expect(afterK.focusedId).toBe('tab1')
542
+ })
543
+ })
544
+
545
+ // ============================================================================
546
+ // Tier-Specific Rendering Tests
547
+ // ============================================================================
548
+
549
+ describe('tier-specific rendering', () => {
550
+ const sampleNode = createTabsNode({
551
+ tabs: [
552
+ { id: 'tab1', label: 'First', content: 'First content' },
553
+ { id: 'tab2', label: 'Second', content: 'Second content' },
554
+ ],
555
+ activeId: 'tab1',
556
+ })
557
+
558
+ it('text tier renders plain text', async () => {
559
+ const { renderTabs } = await import('../../../renderers/tabs')
560
+
561
+ const ctx = createContext('text')
562
+ const result = renderTabs(sampleNode, ctx)
563
+
564
+ expect(result).toContain('First')
565
+ expect(result).toContain('Second')
566
+ expect(result).toContain('First content')
567
+
568
+ // No ANSI codes
569
+ expect(result).not.toContain('\x1b[')
570
+ })
571
+
572
+ it('markdown tier uses formatting', async () => {
573
+ const { renderTabs } = await import('../../../renderers/tabs')
574
+
575
+ const ctx = createContext('markdown')
576
+ const result = renderTabs(sampleNode, ctx)
577
+
578
+ // Should use markdown formatting
579
+ expect(result).toMatch(/[#*|\-]/)
580
+ })
581
+
582
+ it('ascii tier uses ASCII characters', async () => {
583
+ const { renderTabs } = await import('../../../renderers/tabs')
584
+
585
+ const ctx = createContext('ascii')
586
+ const result = renderTabs(sampleNode, ctx)
587
+
588
+ // ASCII art for tab borders
589
+ expect(result).toMatch(/[|+\-=]/)
590
+ })
591
+
592
+ it('unicode tier uses box drawing', async () => {
593
+ const { renderTabs } = await import('../../../renderers/tabs')
594
+
595
+ const ctx = createContext('unicode')
596
+ const result = renderTabs(sampleNode, ctx)
597
+
598
+ // Unicode box drawing
599
+ expect(result).toMatch(/[\u2500-\u257F]/)
600
+ })
601
+
602
+ it('ansi tier includes colors', async () => {
603
+ const { renderTabs } = await import('../../../renderers/tabs')
604
+
605
+ const ctx = createContext('ansi')
606
+ const result = renderTabs(sampleNode, ctx)
607
+
608
+ expect(result).toContain('\x1b[')
609
+ })
610
+
611
+ it('interactive tier includes focus indicators', async () => {
612
+ const { renderTabs } = await import('../../../renderers/tabs')
613
+
614
+ const ctx = createContext('interactive')
615
+ const result = renderTabs(sampleNode, ctx)
616
+
617
+ expect(result).toContain('\x1b[')
618
+ })
619
+ })
620
+
621
+ // ============================================================================
622
+ // Keyboard Navigation Tests (INTERACTIVE Tier)
623
+ // ============================================================================
624
+
625
+ describe('keyboard navigation (interactive tier)', () => {
626
+ it('provides keyboard bindings', async () => {
627
+ const { getTabsKeyBindings } = await import('../../../renderers/tabs')
628
+
629
+ const bindings = getTabsKeyBindings()
630
+
631
+ expect(bindings.left).toBe('focus-prev')
632
+ expect(bindings.right).toBe('focus-next')
633
+ expect(bindings.h).toBe('focus-prev')
634
+ expect(bindings.l).toBe('focus-next')
635
+ expect(bindings.enter).toBe('activate')
636
+ expect(bindings.space).toBe('activate')
637
+ expect(bindings.home).toBe('focus-first')
638
+ expect(bindings.end).toBe('focus-last')
639
+ })
640
+
641
+ it('left/right or h/l moves focus between tabs', async () => {
642
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
643
+
644
+ const state = createTabsState({
645
+ tabs: [
646
+ { id: 'tab1', label: 'Tab 1' },
647
+ { id: 'tab2', label: 'Tab 2' },
648
+ { id: 'tab3', label: 'Tab 3' },
649
+ ],
650
+ activeId: 'tab1',
651
+ focusedId: 'tab1',
652
+ })
653
+
654
+ // Move right
655
+ const afterRight = handleTabsKey(state, 'right')
656
+ expect(afterRight.focusedId).toBe('tab2')
657
+
658
+ const afterL = handleTabsKey(afterRight, 'l')
659
+ expect(afterL.focusedId).toBe('tab3')
660
+
661
+ // Move left
662
+ const afterLeft = handleTabsKey(afterL, 'left')
663
+ expect(afterLeft.focusedId).toBe('tab2')
664
+
665
+ const afterH = handleTabsKey(afterLeft, 'h')
666
+ expect(afterH.focusedId).toBe('tab1')
667
+ })
668
+
669
+ it('enter or space activates focused tab', async () => {
670
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
671
+
672
+ const onTabChange = vi.fn()
673
+
674
+ const state = createTabsState({
675
+ tabs: [
676
+ { id: 'tab1', label: 'Tab 1' },
677
+ { id: 'tab2', label: 'Tab 2' },
678
+ ],
679
+ activeId: 'tab1',
680
+ focusedId: 'tab2',
681
+ onTabChange,
682
+ })
683
+
684
+ handleTabsKey(state, 'enter')
685
+ expect(onTabChange).toHaveBeenCalledWith('tab2')
686
+
687
+ onTabChange.mockClear()
688
+
689
+ handleTabsKey(state, 'space')
690
+ expect(onTabChange).toHaveBeenCalledWith('tab2')
691
+ })
692
+
693
+ it('home moves focus to first tab', async () => {
694
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
695
+
696
+ const state = createTabsState({
697
+ tabs: [
698
+ { id: 'tab1', label: 'Tab 1' },
699
+ { id: 'tab2', label: 'Tab 2' },
700
+ { id: 'tab3', label: 'Tab 3' },
701
+ ],
702
+ activeId: 'tab1',
703
+ focusedId: 'tab3',
704
+ })
705
+
706
+ const afterHome = handleTabsKey(state, 'home')
707
+ expect(afterHome.focusedId).toBe('tab1')
708
+ })
709
+
710
+ it('end moves focus to last tab', async () => {
711
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
712
+
713
+ const state = createTabsState({
714
+ tabs: [
715
+ { id: 'tab1', label: 'Tab 1' },
716
+ { id: 'tab2', label: 'Tab 2' },
717
+ { id: 'tab3', label: 'Tab 3' },
718
+ ],
719
+ activeId: 'tab1',
720
+ focusedId: 'tab1',
721
+ })
722
+
723
+ const afterEnd = handleTabsKey(state, 'end')
724
+ expect(afterEnd.focusedId).toBe('tab3')
725
+ })
726
+
727
+ it('skips disabled tabs during navigation', async () => {
728
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
729
+
730
+ const state = createTabsState({
731
+ tabs: [
732
+ { id: 'tab1', label: 'Tab 1' },
733
+ { id: 'disabled', label: 'Disabled', disabled: true },
734
+ { id: 'tab3', label: 'Tab 3' },
735
+ ],
736
+ activeId: 'tab1',
737
+ focusedId: 'tab1',
738
+ })
739
+
740
+ // Move right should skip disabled
741
+ const afterRight = handleTabsKey(state, 'right')
742
+ expect(afterRight.focusedId).toBe('tab3')
743
+ })
744
+
745
+ it('wraps focus at boundaries when wrap enabled', async () => {
746
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
747
+
748
+ const state = createTabsState({
749
+ tabs: [
750
+ { id: 'tab1', label: 'Tab 1' },
751
+ { id: 'tab2', label: 'Tab 2' },
752
+ ],
753
+ activeId: 'tab1',
754
+ focusedId: 'tab2',
755
+ wrapNavigation: true,
756
+ })
757
+
758
+ // Move right from last should wrap to first
759
+ const afterRight = handleTabsKey(state, 'right')
760
+ expect(afterRight.focusedId).toBe('tab1')
761
+ })
762
+
763
+ it('stops at boundaries when wrap disabled', async () => {
764
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
765
+
766
+ const state = createTabsState({
767
+ tabs: [
768
+ { id: 'tab1', label: 'Tab 1' },
769
+ { id: 'tab2', label: 'Tab 2' },
770
+ ],
771
+ activeId: 'tab1',
772
+ focusedId: 'tab2',
773
+ wrapNavigation: false,
774
+ })
775
+
776
+ // Move right from last should stay at last
777
+ const afterRight = handleTabsKey(state, 'right')
778
+ expect(afterRight.focusedId).toBe('tab2')
779
+ })
780
+
781
+ it('automatic activation mode activates on focus', async () => {
782
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
783
+
784
+ const onTabChange = vi.fn()
785
+
786
+ const state = createTabsState({
787
+ tabs: [
788
+ { id: 'tab1', label: 'Tab 1' },
789
+ { id: 'tab2', label: 'Tab 2' },
790
+ ],
791
+ activeId: 'tab1',
792
+ focusedId: 'tab1',
793
+ onTabChange,
794
+ activationMode: 'automatic',
795
+ })
796
+
797
+ // Moving focus should also activate
798
+ const afterRight = handleTabsKey(state, 'right')
799
+ expect(afterRight.activeId).toBe('tab2')
800
+ expect(onTabChange).toHaveBeenCalledWith('tab2')
801
+ })
802
+
803
+ it('manual activation mode only activates on enter/space', async () => {
804
+ const { createTabsState, handleTabsKey } = await import('../../../renderers/tabs')
805
+
806
+ const onTabChange = vi.fn()
807
+
808
+ const state = createTabsState({
809
+ tabs: [
810
+ { id: 'tab1', label: 'Tab 1' },
811
+ { id: 'tab2', label: 'Tab 2' },
812
+ ],
813
+ activeId: 'tab1',
814
+ focusedId: 'tab1',
815
+ onTabChange,
816
+ activationMode: 'manual',
817
+ })
818
+
819
+ // Moving focus should NOT activate
820
+ const afterRight = handleTabsKey(state, 'right')
821
+ expect(afterRight.focusedId).toBe('tab2')
822
+ expect(afterRight.activeId).toBe('tab1')
823
+ expect(onTabChange).not.toHaveBeenCalled()
824
+
825
+ // Enter should activate
826
+ handleTabsKey(afterRight, 'enter')
827
+ expect(onTabChange).toHaveBeenCalledWith('tab2')
828
+ })
829
+ })
830
+
831
+ // ============================================================================
832
+ // Tab Styling Variants Tests
833
+ // ============================================================================
834
+
835
+ describe('styling variants', () => {
836
+ it('default variant renders standard tabs', async () => {
837
+ const { renderTabs } = await import('../../../renderers/tabs')
838
+
839
+ const node = createTabsNode({
840
+ tabs: [{ id: 'tab1', label: 'Tab' }],
841
+ activeId: 'tab1',
842
+ variant: 'default',
843
+ })
844
+
845
+ const ctx = createContext('unicode')
846
+ const result = renderTabs(node, ctx)
847
+
848
+ expect(result).toContain('Tab')
849
+ })
850
+
851
+ it('pills variant renders rounded pill style', async () => {
852
+ const { renderTabs } = await import('../../../renderers/tabs')
853
+
854
+ const node = createTabsNode({
855
+ tabs: [
856
+ { id: 'tab1', label: 'Tab One' },
857
+ { id: 'tab2', label: 'Tab Two' },
858
+ ],
859
+ activeId: 'tab1',
860
+ variant: 'pills',
861
+ })
862
+
863
+ const ctx = createContext('unicode')
864
+ const result = renderTabs(node, ctx)
865
+
866
+ // Pills have rounded corners or background indicators
867
+ expect(result).toContain('Tab One')
868
+ expect(result).toContain('Tab Two')
869
+ })
870
+
871
+ it('underline variant shows underline indicator', async () => {
872
+ const { renderTabs } = await import('../../../renderers/tabs')
873
+
874
+ const node = createTabsNode({
875
+ tabs: [
876
+ { id: 'tab1', label: 'Tab One' },
877
+ { id: 'tab2', label: 'Tab Two' },
878
+ ],
879
+ activeId: 'tab1',
880
+ variant: 'underline',
881
+ })
882
+
883
+ const ctx = createContext('unicode')
884
+ const result = renderTabs(node, ctx)
885
+
886
+ // Underline characters below active tab
887
+ expect(result).toMatch(/\u2500{2,}|\u2581{2,}|_{2,}|-{2,}/)
888
+ })
889
+ })
890
+
891
+ // ============================================================================
892
+ // All Tiers Rendering Tests
893
+ // ============================================================================
894
+
895
+ describe('renders across all tiers', () => {
896
+ const sampleNode = createTabsNode({
897
+ tabs: [
898
+ { id: 'tab1', label: 'Alpha', content: 'Alpha content' },
899
+ { id: 'tab2', label: 'Beta', content: 'Beta content' },
900
+ ],
901
+ activeId: 'tab1',
902
+ })
903
+
904
+ RENDER_TIERS.forEach((tier) => {
905
+ it(`renders on ${tier} tier`, async () => {
906
+ const { renderTabs } = await import('../../../renderers/tabs')
907
+
908
+ const ctx = createContext(tier)
909
+ const result = renderTabs(sampleNode, ctx)
910
+
911
+ // Should produce output
912
+ expect(result.length).toBeGreaterThan(0)
913
+
914
+ // Should contain tab labels
915
+ expect(result).toContain('Alpha')
916
+ expect(result).toContain('Beta')
917
+
918
+ // Should show active content
919
+ expect(result).toContain('Alpha content')
920
+ })
921
+ })
922
+ })
923
+
924
+ // ============================================================================
925
+ // Tab Width and Layout Tests
926
+ // ============================================================================
927
+
928
+ describe('width and layout', () => {
929
+ it('tabs stretch to fit container width', async () => {
930
+ const { renderTabs } = await import('../../../renderers/tabs')
931
+
932
+ const node = createTabsNode({
933
+ tabs: [
934
+ { id: 'tab1', label: 'Short' },
935
+ { id: 'tab2', label: 'Medium Tab' },
936
+ ],
937
+ activeId: 'tab1',
938
+ })
939
+
940
+ const ctx = createContext('unicode')
941
+ ctx.width = 60
942
+
943
+ const result = renderTabs(node, ctx)
944
+ const lines = result.split('\n')
945
+
946
+ // Tab row should use available width
947
+ const tabLine = lines.find((l) => l.includes('Short'))
948
+ if (tabLine) {
949
+ const stripped = tabLine.replace(/\x1b\[[\d;]*m/g, '')
950
+ expect(stripped.length).toBeGreaterThan(20)
951
+ }
952
+ })
953
+
954
+ it('truncates long tab labels', async () => {
955
+ const { renderTabs } = await import('../../../renderers/tabs')
956
+
957
+ const node = createTabsNode({
958
+ tabs: [
959
+ { id: 'tab1', label: 'This is a very long tab label that should be truncated' },
960
+ { id: 'tab2', label: 'Another Long Tab Label' },
961
+ ],
962
+ activeId: 'tab1',
963
+ })
964
+
965
+ const ctx = createContext('unicode')
966
+ ctx.width = 40
967
+
968
+ const result = renderTabs(node, ctx)
969
+
970
+ // Should contain ellipsis for truncated labels
971
+ expect(result).toMatch(/\u2026|\.\.\./)
972
+ })
973
+
974
+ it('respects minimum tab width', async () => {
975
+ const { renderTabs } = await import('../../../renderers/tabs')
976
+
977
+ const node = createTabsNode({
978
+ tabs: [
979
+ { id: 'tab1', label: 'A' },
980
+ { id: 'tab2', label: 'B' },
981
+ { id: 'tab3', label: 'C' },
982
+ ],
983
+ activeId: 'tab1',
984
+ })
985
+
986
+ const ctx = createContext('unicode')
987
+ const result = renderTabs(node, ctx)
988
+
989
+ // Each tab should have minimum padding around label
990
+ expect(result).toContain('A')
991
+ expect(result).toContain('B')
992
+ expect(result).toContain('C')
993
+ })
994
+ })
995
+ })