@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,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal Tabs Renderer
|
|
3
|
+
*
|
|
4
|
+
* Multi-tier renderer for tabs navigation component.
|
|
5
|
+
* Supports all 6 tiers: text, markdown, ascii, unicode, ansi, interactive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UINode, RenderContext, RenderTier } from '../core/types'
|
|
9
|
+
import {
|
|
10
|
+
matchPath,
|
|
11
|
+
findActiveItemByPath,
|
|
12
|
+
type RouteMatchMode,
|
|
13
|
+
type RouterAdapter,
|
|
14
|
+
} from './utils'
|
|
15
|
+
|
|
16
|
+
// ANSI constants
|
|
17
|
+
const RESET = '\x1b[0m'
|
|
18
|
+
const BOLD = '\x1b[1m'
|
|
19
|
+
const DIM = '\x1b[2m'
|
|
20
|
+
const UNDERLINE = '\x1b[4m'
|
|
21
|
+
const INVERSE = '\x1b[7m'
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
interface Tab {
|
|
28
|
+
id: string
|
|
29
|
+
label: string
|
|
30
|
+
icon?: string
|
|
31
|
+
badge?: string | number
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
content?: string | UINode
|
|
34
|
+
/** Path associated with this tab for route-based switching */
|
|
35
|
+
path?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TabsProps {
|
|
39
|
+
tabs: Tab[]
|
|
40
|
+
activeId: string
|
|
41
|
+
variant?: 'default' | 'pills' | 'underline'
|
|
42
|
+
orientation?: 'horizontal' | 'vertical'
|
|
43
|
+
onTabChange?: (tabId: string) => void
|
|
44
|
+
/** Current route path for automatic active tab detection */
|
|
45
|
+
currentPath?: string
|
|
46
|
+
/** Route matching mode: 'exact', 'prefix', or 'pattern' */
|
|
47
|
+
routeMatchMode?: RouteMatchMode
|
|
48
|
+
/** Router adapter for framework integration */
|
|
49
|
+
router?: RouterAdapter
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TabsState {
|
|
53
|
+
tabs: Tab[]
|
|
54
|
+
activeId: string
|
|
55
|
+
focusedId: string
|
|
56
|
+
orientation?: 'horizontal' | 'vertical'
|
|
57
|
+
wrapNavigation?: boolean
|
|
58
|
+
onTabChange?: (tabId: string) => void
|
|
59
|
+
activationMode?: 'automatic' | 'manual'
|
|
60
|
+
/** Current route path for automatic active detection */
|
|
61
|
+
currentPath?: string
|
|
62
|
+
/** Route matching mode */
|
|
63
|
+
routeMatchMode?: RouteMatchMode
|
|
64
|
+
/** Router adapter for framework integration */
|
|
65
|
+
router?: RouterAdapter
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Main Render Function
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
export function renderTabs(node: UINode, ctx: RenderContext): string {
|
|
73
|
+
const props = node.props as unknown as TabsProps
|
|
74
|
+
const {
|
|
75
|
+
activeId: providedActiveId,
|
|
76
|
+
variant = 'default',
|
|
77
|
+
orientation = 'horizontal',
|
|
78
|
+
currentPath,
|
|
79
|
+
routeMatchMode = 'exact',
|
|
80
|
+
router,
|
|
81
|
+
} = props
|
|
82
|
+
|
|
83
|
+
// Extract tabs from node structure
|
|
84
|
+
const tabs = extractTabs(node)
|
|
85
|
+
|
|
86
|
+
if (!tabs || tabs.length === 0) {
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Determine active tab ID - prefer explicit activeId, then route-based detection
|
|
91
|
+
let activeId: string = providedActiveId || ''
|
|
92
|
+
if (!activeId && currentPath) {
|
|
93
|
+
activeId = findActiveTabByPath(tabs, currentPath, routeMatchMode) || ''
|
|
94
|
+
}
|
|
95
|
+
if (!activeId && router) {
|
|
96
|
+
const routerPath = router.getCurrentPath()
|
|
97
|
+
activeId = findActiveTabByPath(tabs, routerPath, routeMatchMode) || ''
|
|
98
|
+
}
|
|
99
|
+
// Fallback to first tab if no active found
|
|
100
|
+
if (!activeId && tabs.length > 0) {
|
|
101
|
+
activeId = tabs[0].id
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tier = ctx.tier
|
|
105
|
+
const maxWidth = ctx.width || 80
|
|
106
|
+
const lines: string[] = []
|
|
107
|
+
|
|
108
|
+
// Render tab list
|
|
109
|
+
const tabList = renderTabList(tabs, activeId, variant, orientation, tier, ctx, maxWidth)
|
|
110
|
+
lines.push(...tabList)
|
|
111
|
+
|
|
112
|
+
// Render active tab content
|
|
113
|
+
const activeTab = tabs.find((t) => t.id === activeId)
|
|
114
|
+
if (activeTab && activeTab.content) {
|
|
115
|
+
lines.push('') // Separator
|
|
116
|
+
const contentStr = renderTabContent(activeTab.content, tier, ctx)
|
|
117
|
+
if (contentStr) {
|
|
118
|
+
lines.push(contentStr)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Tab Extraction
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
function extractTabs(node: UINode): Tab[] {
|
|
130
|
+
const props = node.props as unknown as TabsProps
|
|
131
|
+
if (props.tabs) {
|
|
132
|
+
return props.tabs
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Extract from children if structured as tab-list
|
|
136
|
+
const tabs: Tab[] = []
|
|
137
|
+
const rawChildren = node.children
|
|
138
|
+
const children: UINode[] = Array.isArray(rawChildren) ? rawChildren : []
|
|
139
|
+
|
|
140
|
+
for (const child of children) {
|
|
141
|
+
if (child.type === 'tab-list') {
|
|
142
|
+
const rawTabItems = child.children
|
|
143
|
+
const tabItems: UINode[] = Array.isArray(rawTabItems) ? rawTabItems : []
|
|
144
|
+
for (const tabItem of tabItems) {
|
|
145
|
+
if (tabItem.type === 'tab') {
|
|
146
|
+
const tabItemProps = tabItem.props || {}
|
|
147
|
+
tabs.push({
|
|
148
|
+
id: tabItemProps.id as string,
|
|
149
|
+
label: tabItemProps.label as string,
|
|
150
|
+
icon: tabItemProps.icon as string | undefined,
|
|
151
|
+
badge: tabItemProps.badge as string | number | undefined,
|
|
152
|
+
disabled: tabItemProps.disabled as boolean | undefined,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (child.type === 'tab-panel') {
|
|
157
|
+
// Find corresponding tab and attach content
|
|
158
|
+
const childProps = child.props || {}
|
|
159
|
+
const tabId = childProps.tabId as string
|
|
160
|
+
const tab = tabs.find((t) => t.id === tabId)
|
|
161
|
+
if (tab) {
|
|
162
|
+
const rawPanelChildren = child.children
|
|
163
|
+
const panelChildren: UINode[] = Array.isArray(rawPanelChildren) ? rawPanelChildren : []
|
|
164
|
+
if (panelChildren.length > 0) {
|
|
165
|
+
const textChild = panelChildren[0]
|
|
166
|
+
if (textChild.type === 'text') {
|
|
167
|
+
const textChildProps = textChild.props || {}
|
|
168
|
+
tab.content = textChildProps.content as string
|
|
169
|
+
} else {
|
|
170
|
+
tab.content = textChild
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return tabs
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Tab List Rendering
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
function renderTabList(
|
|
185
|
+
tabs: Tab[],
|
|
186
|
+
activeId: string,
|
|
187
|
+
variant: 'default' | 'pills' | 'underline',
|
|
188
|
+
orientation: 'horizontal' | 'vertical',
|
|
189
|
+
tier: RenderTier,
|
|
190
|
+
ctx: RenderContext,
|
|
191
|
+
maxWidth: number
|
|
192
|
+
): string[] {
|
|
193
|
+
const lines: string[] = []
|
|
194
|
+
|
|
195
|
+
if (orientation === 'vertical') {
|
|
196
|
+
// Vertical: each tab on its own line
|
|
197
|
+
for (const tab of tabs) {
|
|
198
|
+
const isActive = tab.id === activeId
|
|
199
|
+
const tabStr = renderSingleTab(tab, isActive, variant, tier, ctx)
|
|
200
|
+
lines.push(tabStr)
|
|
201
|
+
}
|
|
202
|
+
return lines
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Horizontal: all tabs on one line
|
|
206
|
+
const tabParts: string[] = []
|
|
207
|
+
const underlineParts: string[] = []
|
|
208
|
+
|
|
209
|
+
for (const tab of tabs) {
|
|
210
|
+
const isActive = tab.id === activeId
|
|
211
|
+
const tabStr = renderSingleTab(tab, isActive, variant, tier, ctx)
|
|
212
|
+
tabParts.push(tabStr)
|
|
213
|
+
|
|
214
|
+
// For underline variant, track underline parts
|
|
215
|
+
if (variant === 'underline' && tier !== 'text' && tier !== 'markdown') {
|
|
216
|
+
const labelLen = stripAnsi(tabStr).length
|
|
217
|
+
if (isActive) {
|
|
218
|
+
const underlineChar = tier === 'ascii' ? '-' : '\u2500'
|
|
219
|
+
underlineParts.push(underlineChar.repeat(labelLen))
|
|
220
|
+
} else {
|
|
221
|
+
underlineParts.push(' '.repeat(labelLen))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Join tabs with separator
|
|
227
|
+
const separator = tier === 'text' || tier === 'markdown' || tier === 'ascii' ? ' | ' : ' \u2502 '
|
|
228
|
+
let tabLine = tabParts.join(separator)
|
|
229
|
+
let finalParts = tabParts
|
|
230
|
+
|
|
231
|
+
// Check if we need to truncate individual tab labels (when total is too wide)
|
|
232
|
+
let currentWidth = stripAnsi(tabLine).length
|
|
233
|
+
if (currentWidth > maxWidth) {
|
|
234
|
+
// Truncate individual tab labels
|
|
235
|
+
const truncatedParts: string[] = []
|
|
236
|
+
const separatorWidth = separator.length
|
|
237
|
+
const availableForTabs = maxWidth - (separatorWidth * (tabs.length - 1))
|
|
238
|
+
const maxLabelWidth = Math.max(5, Math.floor(availableForTabs / tabs.length) - 4) // Reserve space for styling
|
|
239
|
+
|
|
240
|
+
for (const tab of tabs) {
|
|
241
|
+
const isActive = tab.id === activeId
|
|
242
|
+
let label = tab.label
|
|
243
|
+
|
|
244
|
+
// Truncate label if too long
|
|
245
|
+
if (label.length > maxLabelWidth) {
|
|
246
|
+
const ellipsis = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? '\u2026' : '...'
|
|
247
|
+
label = label.slice(0, maxLabelWidth - 1) + ellipsis
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const truncatedTab: Tab = { ...tab, label }
|
|
251
|
+
const tabStr = renderSingleTab(truncatedTab, isActive, variant, tier, ctx)
|
|
252
|
+
truncatedParts.push(tabStr)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
tabLine = truncatedParts.join(separator)
|
|
256
|
+
finalParts = truncatedParts
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Stretch tabs to fill available width (only add modest padding)
|
|
260
|
+
currentWidth = stripAnsi(tabLine).length
|
|
261
|
+
if (currentWidth < maxWidth && tabs.length > 1) {
|
|
262
|
+
const extraSpace = maxWidth - currentWidth
|
|
263
|
+
const paddingPerSide = Math.min(4, Math.floor(extraSpace / (2 * (tabs.length - 1))))
|
|
264
|
+
if (paddingPerSide > 0) {
|
|
265
|
+
const paddedParts = finalParts.map((part, idx) => {
|
|
266
|
+
if (idx === 0) return part + ' '.repeat(paddingPerSide)
|
|
267
|
+
if (idx === finalParts.length - 1) return ' '.repeat(paddingPerSide) + part
|
|
268
|
+
return ' '.repeat(paddingPerSide) + part + ' '.repeat(paddingPerSide)
|
|
269
|
+
})
|
|
270
|
+
tabLine = paddedParts.join(separator)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
lines.push(tabLine)
|
|
275
|
+
|
|
276
|
+
// Add underline row for underline variant
|
|
277
|
+
if (variant === 'underline' && underlineParts.length > 0) {
|
|
278
|
+
const underlineSep = ' '.repeat(separator.length)
|
|
279
|
+
lines.push(underlineParts.join(underlineSep))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return lines
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderSingleTab(
|
|
286
|
+
tab: Tab,
|
|
287
|
+
isActive: boolean,
|
|
288
|
+
variant: 'default' | 'pills' | 'underline',
|
|
289
|
+
tier: RenderTier,
|
|
290
|
+
ctx: RenderContext
|
|
291
|
+
): string {
|
|
292
|
+
let label = tab.label
|
|
293
|
+
|
|
294
|
+
// Add icon for unicode/ansi/interactive
|
|
295
|
+
if (tab.icon && (tier === 'unicode' || tier === 'ansi' || tier === 'interactive')) {
|
|
296
|
+
label = tab.icon + ' ' + label
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Add badge
|
|
300
|
+
if (tab.badge !== undefined) {
|
|
301
|
+
label = label + ` (${tab.badge})`
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Text tier
|
|
305
|
+
if (tier === 'text') {
|
|
306
|
+
if (tab.disabled) {
|
|
307
|
+
return `${label} (disabled)`
|
|
308
|
+
}
|
|
309
|
+
return isActive ? `[${label}]` : label
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Markdown tier
|
|
313
|
+
if (tier === 'markdown') {
|
|
314
|
+
if (tab.disabled) {
|
|
315
|
+
return `~${label}~`
|
|
316
|
+
}
|
|
317
|
+
return isActive ? `**${label}**` : label
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ASCII tier
|
|
321
|
+
if (tier === 'ascii') {
|
|
322
|
+
if (tab.disabled) {
|
|
323
|
+
return `${label} (disabled)`
|
|
324
|
+
}
|
|
325
|
+
if (variant === 'pills' && isActive) {
|
|
326
|
+
return `( ${label} )`
|
|
327
|
+
}
|
|
328
|
+
return isActive ? `[${label}]` : label
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Unicode tier
|
|
332
|
+
if (tier === 'unicode') {
|
|
333
|
+
if (tab.disabled) {
|
|
334
|
+
return label
|
|
335
|
+
}
|
|
336
|
+
if (variant === 'pills' && isActive) {
|
|
337
|
+
return `\u256D ${label} \u256E`
|
|
338
|
+
}
|
|
339
|
+
return label
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ANSI tier
|
|
343
|
+
if (tier === 'ansi') {
|
|
344
|
+
if (tab.disabled) {
|
|
345
|
+
return ctx.theme.muted + label + RESET
|
|
346
|
+
}
|
|
347
|
+
if (isActive) {
|
|
348
|
+
return ctx.theme.primary + BOLD + label + RESET
|
|
349
|
+
}
|
|
350
|
+
return label
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Interactive tier
|
|
354
|
+
if (tier === 'interactive') {
|
|
355
|
+
if (tab.disabled) {
|
|
356
|
+
return ctx.theme.muted + label + RESET
|
|
357
|
+
}
|
|
358
|
+
if (isActive) {
|
|
359
|
+
return INVERSE + label + RESET
|
|
360
|
+
}
|
|
361
|
+
return label
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return label
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ============================================================================
|
|
368
|
+
// Tab Content Rendering
|
|
369
|
+
// ============================================================================
|
|
370
|
+
|
|
371
|
+
function renderTabContent(content: string | UINode, tier: RenderTier, ctx: RenderContext): string {
|
|
372
|
+
if (typeof content === 'string') {
|
|
373
|
+
return content
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const contentProps = content.props || {}
|
|
377
|
+
|
|
378
|
+
// For UINode content, render recursively
|
|
379
|
+
if (content.type === 'text') {
|
|
380
|
+
return contentProps.content as string
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// For complex nodes, render their text content
|
|
384
|
+
if (content.type === 'box') {
|
|
385
|
+
const rawChildren = content.children
|
|
386
|
+
const children: UINode[] = Array.isArray(rawChildren) ? rawChildren : []
|
|
387
|
+
const parts: string[] = []
|
|
388
|
+
for (const child of children) {
|
|
389
|
+
if (child.type === 'text') {
|
|
390
|
+
const childProps = child.props || {}
|
|
391
|
+
parts.push(childProps.content as string)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return parts.join('\n')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return ''
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ============================================================================
|
|
401
|
+
// Keyboard Bindings
|
|
402
|
+
// ============================================================================
|
|
403
|
+
|
|
404
|
+
export function getTabsKeyBindings(): Record<string, string> {
|
|
405
|
+
return {
|
|
406
|
+
left: 'focus-prev',
|
|
407
|
+
right: 'focus-next',
|
|
408
|
+
h: 'focus-prev',
|
|
409
|
+
l: 'focus-next',
|
|
410
|
+
j: 'focus-next', // For vertical orientation
|
|
411
|
+
k: 'focus-prev', // For vertical orientation
|
|
412
|
+
enter: 'activate',
|
|
413
|
+
space: 'activate',
|
|
414
|
+
home: 'focus-first',
|
|
415
|
+
end: 'focus-last',
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================================================
|
|
420
|
+
// State Management
|
|
421
|
+
// ============================================================================
|
|
422
|
+
|
|
423
|
+
export function createTabsState(config: {
|
|
424
|
+
tabs: Tab[]
|
|
425
|
+
activeId: string
|
|
426
|
+
focusedId?: string
|
|
427
|
+
orientation?: 'horizontal' | 'vertical'
|
|
428
|
+
wrapNavigation?: boolean
|
|
429
|
+
onTabChange?: (tabId: string) => void
|
|
430
|
+
activationMode?: 'automatic' | 'manual'
|
|
431
|
+
/** Current route path for automatic active detection */
|
|
432
|
+
currentPath?: string
|
|
433
|
+
/** Route matching mode */
|
|
434
|
+
routeMatchMode?: RouteMatchMode
|
|
435
|
+
/** Router adapter for framework integration */
|
|
436
|
+
router?: RouterAdapter
|
|
437
|
+
}): TabsState {
|
|
438
|
+
const {
|
|
439
|
+
tabs,
|
|
440
|
+
activeId,
|
|
441
|
+
focusedId,
|
|
442
|
+
orientation,
|
|
443
|
+
wrapNavigation,
|
|
444
|
+
onTabChange,
|
|
445
|
+
activationMode,
|
|
446
|
+
currentPath,
|
|
447
|
+
routeMatchMode,
|
|
448
|
+
router,
|
|
449
|
+
} = config
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
tabs,
|
|
453
|
+
activeId,
|
|
454
|
+
focusedId: focusedId || activeId,
|
|
455
|
+
orientation,
|
|
456
|
+
wrapNavigation,
|
|
457
|
+
onTabChange,
|
|
458
|
+
activationMode: activationMode || 'manual',
|
|
459
|
+
currentPath,
|
|
460
|
+
routeMatchMode,
|
|
461
|
+
router,
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function handleTabsKey(state: TabsState, key: string): TabsState {
|
|
466
|
+
const { tabs, focusedId, orientation, wrapNavigation, onTabChange, activationMode, router } = state
|
|
467
|
+
const enabledTabs = tabs.filter((t) => !t.disabled)
|
|
468
|
+
|
|
469
|
+
// Helper to handle tab activation (with router support)
|
|
470
|
+
const activateTab = (tabId: string): void => {
|
|
471
|
+
const tab = tabs.find((t) => t.id === tabId)
|
|
472
|
+
// Use router adapter if available and tab has a path, otherwise use callback
|
|
473
|
+
if (tab?.path && router) {
|
|
474
|
+
router.navigate(tab.path)
|
|
475
|
+
} else if (onTabChange) {
|
|
476
|
+
onTabChange(tabId)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const focusedIndex = enabledTabs.findIndex((t) => t.id === focusedId)
|
|
480
|
+
|
|
481
|
+
// Determine movement keys based on orientation
|
|
482
|
+
const isVertical = orientation === 'vertical'
|
|
483
|
+
const nextKeys = isVertical ? ['j', 'down'] : ['l', 'right']
|
|
484
|
+
const prevKeys = isVertical ? ['k', 'up'] : ['h', 'left']
|
|
485
|
+
|
|
486
|
+
if (nextKeys.includes(key)) {
|
|
487
|
+
let newIndex = focusedIndex + 1
|
|
488
|
+
if (newIndex >= enabledTabs.length) {
|
|
489
|
+
newIndex = wrapNavigation ? 0 : enabledTabs.length - 1
|
|
490
|
+
}
|
|
491
|
+
const newFocusedId = enabledTabs[newIndex]?.id || focusedId
|
|
492
|
+
|
|
493
|
+
// Automatic activation
|
|
494
|
+
if (activationMode === 'automatic' && newFocusedId !== focusedId) {
|
|
495
|
+
activateTab(newFocusedId)
|
|
496
|
+
return { ...state, focusedId: newFocusedId, activeId: newFocusedId }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { ...state, focusedId: newFocusedId }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (prevKeys.includes(key)) {
|
|
503
|
+
let newIndex = focusedIndex - 1
|
|
504
|
+
if (newIndex < 0) {
|
|
505
|
+
newIndex = wrapNavigation ? enabledTabs.length - 1 : 0
|
|
506
|
+
}
|
|
507
|
+
const newFocusedId = enabledTabs[newIndex]?.id || focusedId
|
|
508
|
+
|
|
509
|
+
// Automatic activation
|
|
510
|
+
if (activationMode === 'automatic' && newFocusedId !== focusedId) {
|
|
511
|
+
activateTab(newFocusedId)
|
|
512
|
+
return { ...state, focusedId: newFocusedId, activeId: newFocusedId }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { ...state, focusedId: newFocusedId }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
switch (key) {
|
|
519
|
+
case 'home': {
|
|
520
|
+
const firstTab = enabledTabs[0]
|
|
521
|
+
if (firstTab) {
|
|
522
|
+
return { ...state, focusedId: firstTab.id }
|
|
523
|
+
}
|
|
524
|
+
return state
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
case 'end': {
|
|
528
|
+
const lastTab = enabledTabs[enabledTabs.length - 1]
|
|
529
|
+
if (lastTab) {
|
|
530
|
+
return { ...state, focusedId: lastTab.id }
|
|
531
|
+
}
|
|
532
|
+
return state
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
case 'enter':
|
|
536
|
+
case 'space': {
|
|
537
|
+
activateTab(focusedId)
|
|
538
|
+
return { ...state, activeId: focusedId }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
default:
|
|
542
|
+
return state
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ============================================================================
|
|
547
|
+
// Utility Functions
|
|
548
|
+
// ============================================================================
|
|
549
|
+
|
|
550
|
+
function stripAnsi(str: string): string {
|
|
551
|
+
return str.replace(/\x1b\[[\d;]*m/g, '')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function truncateToWidth(line: string, maxWidth: number, tier: RenderTier): string {
|
|
555
|
+
const stripped = stripAnsi(line)
|
|
556
|
+
if (stripped.length <= maxWidth) return line
|
|
557
|
+
|
|
558
|
+
const ellipsis = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? '\u2026' : '...'
|
|
559
|
+
const ellipsisLen = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? 1 : 3
|
|
560
|
+
|
|
561
|
+
let visibleLen = 0
|
|
562
|
+
let result = ''
|
|
563
|
+
let inEscape = false
|
|
564
|
+
|
|
565
|
+
for (let i = 0; i < line.length; i++) {
|
|
566
|
+
const char = line[i]
|
|
567
|
+
|
|
568
|
+
if (char === '\x1b') {
|
|
569
|
+
inEscape = true
|
|
570
|
+
result += char
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (inEscape) {
|
|
575
|
+
result += char
|
|
576
|
+
if (char === 'm') {
|
|
577
|
+
inEscape = false
|
|
578
|
+
}
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (visibleLen >= maxWidth - ellipsisLen) {
|
|
583
|
+
result += ellipsis + RESET
|
|
584
|
+
break
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
result += char
|
|
588
|
+
visibleLen++
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return result
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// Router Integration Functions
|
|
596
|
+
// ============================================================================
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Finds the active tab ID based on the current route path.
|
|
600
|
+
* Tabs with `path` property will be matched against the current path.
|
|
601
|
+
*
|
|
602
|
+
* @param tabs - Array of tabs with optional path property
|
|
603
|
+
* @param currentPath - Current route path
|
|
604
|
+
* @param mode - Route matching mode ('exact', 'prefix', or 'pattern')
|
|
605
|
+
* @returns The ID of the matching tab, or undefined if no match
|
|
606
|
+
*/
|
|
607
|
+
export function findActiveTabByPath(
|
|
608
|
+
tabs: Tab[],
|
|
609
|
+
currentPath: string,
|
|
610
|
+
mode: RouteMatchMode = 'exact'
|
|
611
|
+
): string | undefined {
|
|
612
|
+
// Filter to tabs that have paths
|
|
613
|
+
const tabsWithPaths = tabs.filter((t) => t.path)
|
|
614
|
+
return findActiveItemByPath(tabsWithPaths, currentPath, mode)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Checks if a specific tab is active based on the current route.
|
|
619
|
+
*
|
|
620
|
+
* @param tab - Tab to check
|
|
621
|
+
* @param currentPath - Current route path
|
|
622
|
+
* @param mode - Route matching mode
|
|
623
|
+
* @returns true if the tab is active
|
|
624
|
+
*/
|
|
625
|
+
export function isTabActiveByRoute(
|
|
626
|
+
tab: Tab,
|
|
627
|
+
currentPath: string,
|
|
628
|
+
mode: RouteMatchMode = 'exact'
|
|
629
|
+
): boolean {
|
|
630
|
+
if (!tab.path) return false
|
|
631
|
+
return matchPath(currentPath, tab.path, mode)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Creates a tab change handler that integrates with a router adapter.
|
|
636
|
+
* Returns a function compatible with the onTabChange callback.
|
|
637
|
+
*
|
|
638
|
+
* @param tabs - Array of tabs (needed to look up paths by ID)
|
|
639
|
+
* @param router - Router adapter instance
|
|
640
|
+
* @param fallback - Optional fallback handler if router navigation fails
|
|
641
|
+
* @returns Tab change handler function
|
|
642
|
+
*/
|
|
643
|
+
export function createRouterTabChangeHandler(
|
|
644
|
+
tabs: Tab[],
|
|
645
|
+
router: RouterAdapter,
|
|
646
|
+
fallback?: (tabId: string) => void
|
|
647
|
+
): (tabId: string) => void {
|
|
648
|
+
return (tabId: string) => {
|
|
649
|
+
const tab = tabs.find((t) => t.id === tabId)
|
|
650
|
+
if (tab?.path) {
|
|
651
|
+
router.navigate(tab.path)
|
|
652
|
+
} else if (fallback) {
|
|
653
|
+
fallback(tabId)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Creates tabs from route definitions.
|
|
660
|
+
* Useful for generating tabs from a list of routes.
|
|
661
|
+
*
|
|
662
|
+
* @param routes - Array of route definitions
|
|
663
|
+
* @param labelGenerator - Optional function to generate labels from paths
|
|
664
|
+
* @returns Array of Tab objects
|
|
665
|
+
*/
|
|
666
|
+
export function tabsFromRoutes(
|
|
667
|
+
routes: Array<{
|
|
668
|
+
path: string
|
|
669
|
+
label?: string
|
|
670
|
+
icon?: string
|
|
671
|
+
disabled?: boolean
|
|
672
|
+
}>,
|
|
673
|
+
labelGenerator?: (path: string) => string
|
|
674
|
+
): Tab[] {
|
|
675
|
+
return routes.map((route, index) => ({
|
|
676
|
+
id: route.path || `tab-${index}`,
|
|
677
|
+
label: route.label || (labelGenerator ? labelGenerator(route.path) : route.path),
|
|
678
|
+
path: route.path,
|
|
679
|
+
icon: route.icon,
|
|
680
|
+
disabled: route.disabled,
|
|
681
|
+
}))
|
|
682
|
+
}
|