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