termcast 1.6.0 → 1.7.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 (64) hide show
  1. package/dist/components/bar-chart.d.ts.map +1 -1
  2. package/dist/components/bar-chart.js +14 -3
  3. package/dist/components/bar-chart.js.map +1 -1
  4. package/dist/components/bar-graph.d.ts.map +1 -1
  5. package/dist/components/bar-graph.js +21 -3
  6. package/dist/components/bar-graph.js.map +1 -1
  7. package/dist/components/candle-chart.d.ts +15 -0
  8. package/dist/components/candle-chart.d.ts.map +1 -1
  9. package/dist/components/candle-chart.js +41 -3
  10. package/dist/components/candle-chart.js.map +1 -1
  11. package/dist/components/chart-tooltip.d.ts +83 -0
  12. package/dist/components/chart-tooltip.d.ts.map +1 -0
  13. package/dist/components/chart-tooltip.js +127 -0
  14. package/dist/components/chart-tooltip.js.map +1 -0
  15. package/dist/components/dotted-line-graph.d.ts +11 -0
  16. package/dist/components/dotted-line-graph.d.ts.map +1 -1
  17. package/dist/components/dotted-line-graph.js +43 -2
  18. package/dist/components/dotted-line-graph.js.map +1 -1
  19. package/dist/components/graph.d.ts +11 -0
  20. package/dist/components/graph.d.ts.map +1 -1
  21. package/dist/components/graph.js +53 -4
  22. package/dist/components/graph.js.map +1 -1
  23. package/dist/components/horizontal-bar-graph.d.ts.map +1 -1
  24. package/dist/components/horizontal-bar-graph.js +16 -5
  25. package/dist/components/horizontal-bar-graph.js.map +1 -1
  26. package/dist/components/list.d.ts.map +1 -1
  27. package/dist/components/list.js +1 -3
  28. package/dist/components/list.js.map +1 -1
  29. package/dist/examples/chart-tooltips.d.ts +2 -0
  30. package/dist/examples/chart-tooltips.d.ts.map +1 -0
  31. package/dist/examples/chart-tooltips.js +16 -0
  32. package/dist/examples/chart-tooltips.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/components/bar-chart.tsx +23 -3
  35. package/src/components/bar-graph.tsx +23 -4
  36. package/src/components/candle-chart.tsx +63 -16
  37. package/src/components/chart-tooltip.tsx +191 -0
  38. package/src/components/dotted-line-graph.tsx +49 -3
  39. package/src/components/graph.tsx +76 -18
  40. package/src/components/horizontal-bar-graph.tsx +24 -4
  41. package/src/components/list.tsx +1 -9
  42. package/src/examples/action-shortcut.vitest.tsx +1 -1
  43. package/src/examples/bar-graph-weekly.vitest.tsx +2 -2
  44. package/src/examples/chart-tooltips.tsx +54 -0
  45. package/src/examples/form-basic.vitest.tsx +8 -8
  46. package/src/examples/github.vitest.tsx +3 -3
  47. package/src/examples/graph-bar-chart.vitest.tsx +4 -4
  48. package/src/examples/graph-polymarket.vitest.tsx +2 -2
  49. package/src/examples/graph-row.vitest.tsx +4 -4
  50. package/src/examples/list-detail-height-ratchet.vitest.tsx +3 -3
  51. package/src/examples/list-detail-metadata.vitest.tsx +3 -3
  52. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  53. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  54. package/src/examples/list-no-actions.vitest.tsx +1 -1
  55. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  56. package/src/examples/list-with-detail.vitest.tsx +3 -3
  57. package/src/examples/list-with-dropdown.vitest.tsx +4 -4
  58. package/src/examples/list-with-sections.vitest.tsx +19 -19
  59. package/src/examples/simple-candle-chart.vitest.tsx +4 -4
  60. package/src/examples/simple-grid.vitest.tsx +13 -13
  61. package/src/examples/simple-navigation.vitest.tsx +14 -14
  62. package/src/examples/simple-progress-bar.vitest.tsx +1 -1
  63. package/src/examples/swift-extension.vitest.tsx +2 -2
  64. package/src/examples/toast-action.vitest.tsx +2 -2
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Shared tooltip component for all chart/graph components.
3
+ *
4
+ * Renders an absolute-positioned box near the cursor showing the x-axis label
5
+ * and y-axis value for the hovered data point. Used by BarGraph, Graph,
6
+ * DottedLineGraph, HorizontalBarGraph, BarChart, and CandleChart.
7
+ *
8
+ * Each chart wraps its plot area with a container that handles mouse events
9
+ * and converts cursor coordinates to tooltip content via `useChartTooltip()`.
10
+ */
11
+
12
+ import React, { useState, useRef } from 'react'
13
+ import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
14
+ import { useTheme } from 'termcast/src/theme'
15
+
16
+ // ── Tooltip state ────────────────────────────────────────────────────
17
+
18
+ export interface TooltipData {
19
+ /** Absolute terminal X where the tooltip should appear */
20
+ x: number
21
+ /** Absolute terminal Y where the tooltip should appear */
22
+ y: number
23
+ /** Lines of text to show in the tooltip */
24
+ lines: string[]
25
+ }
26
+
27
+ export interface ChartTooltipState {
28
+ tooltip: TooltipData | null
29
+ show: (data: TooltipData) => void
30
+ hide: () => void
31
+ }
32
+
33
+ /**
34
+ * Hook to manage tooltip state. Each chart component calls this once
35
+ * and passes `show`/`hide` to mouse event handlers.
36
+ */
37
+ export function useChartTooltip(): ChartTooltipState {
38
+ const [tooltip, setTooltip] = useState<TooltipData | null>(null)
39
+ const showRef = useRef((data: TooltipData) => {
40
+ setTooltip(data)
41
+ })
42
+ const hideRef = useRef(() => {
43
+ setTooltip(null)
44
+ })
45
+ return { tooltip, show: showRef.current, hide: hideRef.current }
46
+ }
47
+
48
+ // ── Tooltip component ────────────────────────────────────────────────
49
+
50
+ interface ChartTooltipProps {
51
+ tooltip: TooltipData | null
52
+ /** The ref of the chart container box, used to compute relative position */
53
+ containerRef: React.RefObject<{ x: number; y: number; width: number; height: number } | null>
54
+ }
55
+
56
+ /**
57
+ * Absolute-positioned tooltip that floats near the hovered data point.
58
+ * Must be rendered inside the chart's outermost `<box>` container.
59
+ *
60
+ * Position is computed relative to the container. The tooltip shifts
61
+ * left when it would overflow the right edge, and shifts above the
62
+ * cursor when it would overflow the bottom.
63
+ */
64
+ export function ChartTooltip({ tooltip, containerRef }: ChartTooltipProps): any {
65
+ const theme = useTheme()
66
+
67
+ if (!tooltip || !containerRef.current) {
68
+ return null
69
+ }
70
+
71
+ const container = containerRef.current
72
+ const maxWidth = Math.max(...tooltip.lines.map((line) => line.length)) + 2
73
+ const tooltipHeight = tooltip.lines.length
74
+
75
+ // Convert absolute terminal coordinates to relative container coordinates
76
+ let relX = tooltip.x - container.x + 2
77
+ let relY = tooltip.y - container.y - tooltipHeight
78
+
79
+ // Shift left if tooltip would overflow the right edge
80
+ if (relX + maxWidth > container.width) {
81
+ relX = Math.max(0, container.width - maxWidth)
82
+ }
83
+
84
+ // Shift below cursor if tooltip would overflow the top
85
+ if (relY < 0) {
86
+ relY = tooltip.y - container.y + 1
87
+ }
88
+
89
+ return (
90
+ <box
91
+ position="absolute"
92
+ left={relX}
93
+ top={relY}
94
+ height={tooltipHeight}
95
+ overflow="hidden"
96
+ flexShrink={0}
97
+ backgroundColor={theme.backgroundPanel}
98
+ >
99
+ {tooltip.lines.map((line, i) => {
100
+ return (
101
+ <text key={i} fg={theme.text} wrapMode="none"> {line} </text>
102
+ )
103
+ })}
104
+ </box>
105
+ )
106
+ }
107
+
108
+ // ── Helpers for renderable-based charts ──────────────────────────────
109
+
110
+ /**
111
+ * For renderable-based charts (Graph, DottedLineGraph, CandleChart), compute
112
+ * which data index the cursor is hovering over based on the mouse X coordinate
113
+ * relative to the plot area.
114
+ */
115
+ export function computeDataIndexFromMouseX({
116
+ mouseX,
117
+ plotX,
118
+ plotW,
119
+ dataLength,
120
+ }: {
121
+ mouseX: number
122
+ plotX: number
123
+ plotW: number
124
+ dataLength: number
125
+ }): number {
126
+ if (dataLength <= 0 || plotW <= 0) return -1
127
+ const relX = mouseX - plotX
128
+ if (relX < 0 || relX >= plotW) return -1
129
+ return Math.round((relX / Math.max(1, plotW - 1)) * (dataLength - 1))
130
+ }
131
+
132
+ /**
133
+ * Resolve the x-axis label for a given data index.
134
+ *
135
+ * When xLabels has at least as many entries as data points (1:1 mapping,
136
+ * typical for BarGraph where each bar has its own label), the label is
137
+ * looked up directly by index.
138
+ *
139
+ * When xLabels is shorter (typical for line charts where a few tick labels
140
+ * are spread across many data points), the label is interpolated by mapping
141
+ * the normalized data position to the nearest label.
142
+ *
143
+ * Falls back to the stringified index only if no labels are provided at all.
144
+ */
145
+ export function interpolateXLabel({
146
+ dataIndex,
147
+ dataLength,
148
+ xLabels,
149
+ }: {
150
+ dataIndex: number
151
+ dataLength: number
152
+ xLabels: string[]
153
+ }): string {
154
+ if (xLabels.length === 0) return `${dataIndex}`
155
+
156
+ // 1:1 mapping: xLabels covers every data point
157
+ const hasDirectLabels = xLabels.length >= dataLength
158
+ if (hasDirectLabels) {
159
+ const direct = xLabels[dataIndex]
160
+ if (direct !== undefined && direct !== '') return direct
161
+ return `${dataIndex}`
162
+ }
163
+
164
+ // Sparse labels: interpolate by normalized position
165
+ if (dataLength <= 1) return xLabels[0] || `${dataIndex}`
166
+ const t = dataIndex / (dataLength - 1)
167
+ const labelIdx = Math.round(t * (xLabels.length - 1))
168
+ const resolved = xLabels[labelIdx]
169
+ if (resolved !== undefined && resolved !== '') return resolved
170
+
171
+ // Scan for nearest non-empty label
172
+ let bestIdx = -1
173
+ let bestDist = Infinity
174
+ for (let i = 0; i < xLabels.length; i++) {
175
+ if (xLabels[i] === undefined || xLabels[i] === '') continue
176
+ const dist = Math.abs(i - labelIdx)
177
+ if (dist < bestDist) {
178
+ bestDist = dist
179
+ bestIdx = i
180
+ }
181
+ }
182
+ if (bestIdx >= 0) return xLabels[bestIdx]!
183
+ return `${dataIndex}`
184
+ }
185
+
186
+ /**
187
+ * Format a single tooltip line from a label and numeric value.
188
+ */
189
+ export function formatTooltipLine(label: string, value: number | string): string {
190
+ return `${label}: ${value}`
191
+ }
@@ -5,12 +5,13 @@
5
5
  * can move by half columns and quarter rows instead of snapping to full cells.
6
6
  */
7
7
 
8
- import React, { ReactNode, useMemo } from 'react'
8
+ import React, { ReactNode, useMemo, useRef } from 'react'
9
9
  import { Renderable, RGBA } from '@opentui/core'
10
- import type { OptimizedBuffer, RenderableOptions, RenderContext } from '@opentui/core'
10
+ import type { OptimizedBuffer, RenderableOptions, RenderContext, MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
11
11
  import { extend } from '@opentui/react'
12
12
  import { Color, resolveColor } from 'termcast/src/colors'
13
13
  import { getThemePalette, useTheme } from 'termcast/src/theme'
14
+ import { ChartTooltip, useChartTooltip, computeDataIndexFromMouseX, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
14
15
 
15
16
  const BRAILLE_BITS: number[][] = [
16
17
  [1, 8],
@@ -73,6 +74,12 @@ class DottedLineGraphPlotRenderable extends Renderable {
73
74
  set axisColor(value: string) { this._axisColor = value; this.requestRender() }
74
75
  set dotSpacing(value: number) { this._dotSpacing = value; this.requestRender() }
75
76
 
77
+ /** Public accessor for tooltip coordinate mapping */
78
+ getPlotLayout() { return this.computeLayout() }
79
+
80
+ /** Current series data for tooltip value lookup */
81
+ getSeries() { return this._series }
82
+
76
83
  private computeLayout(): {
77
84
  plotX: number
78
85
  plotY: number
@@ -314,6 +321,9 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
314
321
  children,
315
322
  } = props
316
323
  const palette = getThemePalette(theme)
324
+ const containerRef = useRef<any>(null)
325
+ const plotRef = useRef<DottedLineGraphPlotRenderable>(null)
326
+ const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
317
327
 
318
328
  const series = useMemo<Array<DottedLineGraphSeriesData & { title?: string }>>(() => {
319
329
  return React.Children.toArray(children)
@@ -364,11 +374,46 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
364
374
  })) + 1
365
375
  }, [computedYRange, yFormat, yTicks])
366
376
 
377
+ const handleMouseMove = (evt: OpenTUIMouseEvent) => {
378
+ const plot = plotRef.current
379
+ if (!plot) return
380
+ const layout = plot.getPlotLayout()
381
+ if (!layout) return
382
+
383
+ const allSeries = plot.getSeries()
384
+ const maxDataLen = Math.max(0, ...allSeries.map((s) => s.data.length))
385
+ const idx = computeDataIndexFromMouseX({
386
+ mouseX: evt.x,
387
+ plotX: layout.plotX,
388
+ plotW: layout.plotW,
389
+ dataLength: maxDataLen,
390
+ })
391
+ if (idx < 0) {
392
+ hideTooltip()
393
+ return
394
+ }
395
+
396
+ const label = interpolateXLabel({ dataIndex: idx, dataLength: maxDataLen, xLabels })
397
+ const lines = series
398
+ .filter((s) => idx < s.data.length)
399
+ .map((s) => {
400
+ const value = s.data[idx] ?? 0
401
+ const prefix = s.title ? `${s.title}` : label
402
+ return formatTooltipLine(prefix, Number(value.toFixed(2)))
403
+ })
404
+ if (lines.length === 0) return
405
+ // Always show x-axis label as the header line
406
+ lines.unshift(label)
407
+ showTooltip({ x: evt.x, y: evt.y, lines })
408
+ }
409
+
367
410
  if (series.length === 0) return null
368
411
 
369
412
  return (
370
- <box flexDirection="column" width="100%" flexShrink={0}>
413
+ <box ref={containerRef} flexDirection="column" width="100%" flexShrink={0} onMouseOut={hideTooltip}>
414
+ <ChartTooltip tooltip={tooltip} containerRef={containerRef} />
371
415
  <dotted-line-graph-plot
416
+ ref={plotRef}
372
417
  width="100%"
373
418
  height={height + (xLabels.length > 0 ? 1 : 0)}
374
419
  series={plotSeries}
@@ -379,6 +424,7 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
379
424
  yFormat={yFormat}
380
425
  axisColor={theme.textMuted}
381
426
  dotSpacing={dotSpacing}
427
+ onMouseMove={handleMouseMove}
382
428
  />
383
429
  {legendVisible ? (
384
430
  <box height={1} width="100%" flexShrink={0} overflow="hidden" flexDirection="row">
@@ -20,13 +20,14 @@
20
20
  * For a plot of W cols x H rows we get W*2 x H*4 virtual pixels.
21
21
  */
22
22
 
23
- import React, { ReactNode, useMemo } from 'react'
23
+ import React, { ReactNode, useMemo, useRef } from 'react'
24
24
  import { Renderable, RGBA } from '@opentui/core'
25
- import type { RenderableOptions, RenderContext } from '@opentui/core'
25
+ import type { RenderableOptions, RenderContext, MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
26
26
  import type { OptimizedBuffer } from '@opentui/core'
27
27
  import { extend } from '@opentui/react'
28
28
  import { useTheme, getThemePalette } from 'termcast/src/theme'
29
29
  import { Color, resolveColor } from 'termcast/src/colors'
30
+ import { ChartTooltip, useChartTooltip, computeDataIndexFromMouseX, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
30
31
 
31
32
  // ── Graph variant ────────────────────────────────────────────────────
32
33
  // Three rendering modes for the plot area:
@@ -124,6 +125,12 @@ export class GraphPlotRenderable extends Renderable {
124
125
  set stripeColor1(value: string) { this._stripeColor1 = value; this.requestRender() }
125
126
  set stripeColor2(value: string) { this._stripeColor2 = value; this.requestRender() }
126
127
 
128
+ /** Public accessor for tooltip coordinate mapping */
129
+ getPlotLayout() { return this.computeLayout() }
130
+
131
+ /** Current series data for tooltip value lookup */
132
+ getSeries() { return this._series }
133
+
127
134
  // ── Shared: compute layout and draw axes ─────────────────────
128
135
  private computeLayout(): {
129
136
  plotX: number; plotY: number; plotW: number; plotH: number
@@ -440,10 +447,13 @@ const Graph: GraphType = (props) => {
440
447
  const { height = 15, xLabels = [], yRange, yTicks = 5, yFormat, variant = 'area', stripeColors, children } = props
441
448
 
442
449
  const palette = getThemePalette(theme)
450
+ const containerRef = useRef<any>(null)
451
+ const plotRef = useRef<GraphPlotRenderable>(null)
452
+ const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
443
453
 
444
454
  // Collect series data from Graph.Line children
445
- const series = useMemo<SeriesData[]>(() => {
446
- const result: SeriesData[] = []
455
+ const seriesWithTitles = useMemo<Array<SeriesData & { title?: string }>>(() => {
456
+ const result: Array<SeriesData & { title?: string }> = []
447
457
  let colorIndex = 0
448
458
  React.Children.forEach(children, (child) => {
449
459
  if (!React.isValidElement(child)) return
@@ -454,12 +464,18 @@ const Graph: GraphType = (props) => {
454
464
  result.push({
455
465
  data: childProps.data,
456
466
  color,
467
+ title: childProps.title,
457
468
  })
458
469
  colorIndex++
459
470
  })
460
471
  return result
461
472
  }, [children, palette])
462
473
 
474
+ // Strip titles for the renderable (it only needs data + color)
475
+ const series = useMemo<SeriesData[]>(() => {
476
+ return seriesWithTitles.map((s) => ({ data: s.data, color: s.color }))
477
+ }, [seriesWithTitles])
478
+
463
479
  // Auto-compute Y range if not provided
464
480
  const computedYRange = useMemo<[number, number]>(() => {
465
481
  if (yRange) return yRange
@@ -484,21 +500,63 @@ const Graph: GraphType = (props) => {
484
500
  // Total height = plot rows + 1 for x-axis labels
485
501
  const totalHeight = height + (xLabels.length > 0 ? 1 : 0)
486
502
 
503
+ const handleMouseMove = (evt: OpenTUIMouseEvent) => {
504
+ const plot = plotRef.current
505
+ if (!plot) return
506
+ const layout = plot.getPlotLayout()
507
+ if (!layout) return
508
+
509
+ const allSeries = plot.getSeries()
510
+ const maxDataLen = Math.max(0, ...allSeries.map((s) => s.data.length))
511
+ const idx = computeDataIndexFromMouseX({
512
+ mouseX: evt.x,
513
+ plotX: layout.plotX,
514
+ plotW: layout.plotW,
515
+ dataLength: maxDataLen,
516
+ })
517
+ if (idx < 0) {
518
+ hideTooltip()
519
+ return
520
+ }
521
+
522
+ const label = interpolateXLabel({ dataIndex: idx, dataLength: maxDataLen, xLabels })
523
+ const lines = allSeries
524
+ .map((s, si) => ({ series: s, title: seriesWithTitles[si]?.title }))
525
+ .filter(({ series }) => idx < series.data.length)
526
+ .map(({ series, title }) => {
527
+ const value = series.data[idx]!
528
+ const prefix = title || label
529
+ return formatTooltipLine(prefix, Number(value.toFixed(2)))
530
+ })
531
+ if (lines.length === 0) {
532
+ hideTooltip()
533
+ return
534
+ }
535
+ // Always show x-axis label as the header line
536
+ lines.unshift(label)
537
+ showTooltip({ x: evt.x, y: evt.y, lines })
538
+ }
539
+
487
540
  return (
488
- <graph-plot
489
- width="100%"
490
- height={totalHeight}
491
- series={series}
492
- xLabels={xLabels}
493
- yMin={computedYRange[0]}
494
- yMax={computedYRange[1]}
495
- yTicks={yTicks}
496
- yFormat={yFormat}
497
- axisColor={theme.textMuted}
498
- variant={variant}
499
- stripeColor1={resolvedStripe1}
500
- stripeColor2={resolvedStripe2}
501
- />
541
+ <box ref={containerRef} width="100%" onMouseOut={hideTooltip}>
542
+ <ChartTooltip tooltip={tooltip} containerRef={containerRef} />
543
+ <graph-plot
544
+ ref={plotRef}
545
+ width="100%"
546
+ height={totalHeight}
547
+ series={series}
548
+ xLabels={xLabels}
549
+ yMin={computedYRange[0]}
550
+ yMax={computedYRange[1]}
551
+ yTicks={yTicks}
552
+ yFormat={yFormat}
553
+ axisColor={theme.textMuted}
554
+ variant={variant}
555
+ stripeColor1={resolvedStripe1}
556
+ stripeColor2={resolvedStripe2}
557
+ onMouseMove={handleMouseMove}
558
+ />
559
+ </box>
502
560
  )
503
561
  }
504
562
 
@@ -6,10 +6,12 @@
6
6
  * colored series rows and percentages.
7
7
  */
8
8
 
9
- import React, { ReactNode, useMemo } from 'react'
9
+ import React, { ReactNode, useMemo, useRef } from 'react'
10
10
  import { BoxProps } from '@opentui/react'
11
+ import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
11
12
  import { Color, resolveColor } from 'termcast/src/colors'
12
13
  import { getThemePalette, useTheme } from 'termcast/src/theme'
14
+ import { ChartTooltip, useChartTooltip, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
13
15
 
14
16
  export interface HorizontalBarGraphSeriesProps {
15
17
  /** One value per row/category position. */
@@ -97,6 +99,8 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
97
99
  } = props
98
100
 
99
101
  const palette = getThemePalette(theme)
102
+ const containerRef = useRef<any>(null)
103
+ const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
100
104
 
101
105
  const seriesList = useMemo<Array<{ data: number[]; color: string; title?: string }>>(() => {
102
106
  const childArray = React.Children.toArray(children)
@@ -189,7 +193,8 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
189
193
  const chartHeight = headerHeight + visibleRows.length
190
194
 
191
195
  return (
192
- <box flexDirection="column" width="100%" flexShrink={0} {...rest}>
196
+ <box ref={containerRef} flexDirection="column" width="100%" flexShrink={0} {...rest} onMouseOut={hideTooltip}>
197
+ <ChartTooltip tooltip={tooltip} containerRef={containerRef} />
193
198
  {showHeader && (
194
199
  <>
195
200
  <box flexDirection="row" height={1} flexShrink={0}>
@@ -238,7 +243,22 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
238
243
  }
239
244
  const series = seriesList[seriesIndex]!
240
245
  return (
241
- <box key={seriesIndex} flexGrow={value} flexBasis={0} flexShrink={1} overflow="hidden">
246
+ <box
247
+ key={seriesIndex}
248
+ flexGrow={value}
249
+ flexBasis={0}
250
+ flexShrink={1}
251
+ overflow="hidden"
252
+ onMouseMove={(evt: OpenTUIMouseEvent) => {
253
+ const label = row.label
254
+ const seriesTitle = series.title || `#${seriesIndex + 1}`
255
+ showTooltip({
256
+ x: evt.x,
257
+ y: evt.y,
258
+ lines: [label, formatTooltipLine(seriesTitle, value)],
259
+ })
260
+ }}
261
+ >
242
262
  <box position="absolute" width="100%" height="100%" overflow="hidden">
243
263
  <text fg={series.color} wrapMode="none">{barCharacter.repeat(200)}</text>
244
264
  </box>
@@ -246,7 +266,7 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
246
266
  )
247
267
  })}
248
268
  {maxTotal > row.total && (
249
- <box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} />
269
+ <box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} onMouseMove={hideTooltip} />
250
270
  )}
251
271
  </box>
252
272
  </box>
@@ -164,15 +164,7 @@ function ListFooter(): any {
164
164
  </text>
165
165
  </Hoverable>
166
166
  )}
167
- {!isVim && (
168
- <Hoverable
169
- onMouseDown={() => {
170
- executeVimCommand('vim')
171
- }}
172
- >
173
- <text flexShrink={0} fg={theme.textMuted}>:vim</text>
174
- </Hoverable>
175
- )}
167
+
176
168
  </box>
177
169
  )
178
170
 
@@ -61,7 +61,7 @@ test('ctrl+r shortcut should trigger Refresh action directly', async () => {
61
61
 
62
62
 
63
63
 
64
- ↵ refresh ↑↓ navigate ^k actions :vim
64
+ ↵ refresh ↑↓ navigate ^k actions
65
65
  "
66
66
  `)
67
67
  }, 30000)
@@ -134,7 +134,7 @@ test('many series (8) bottom legend clips on one row', async () => {
134
134
 
135
135
 
136
136
 
137
- ↑↓ navigate ^k actions :vim
137
+ ↑↓ navigate ^k actions
138
138
 
139
139
  "
140
140
  `)
@@ -188,7 +188,7 @@ test('long labels truncated by overflow hidden', async () => {
188
188
 
189
189
 
190
190
 
191
- ↑↓ navigate ^k actions :vim
191
+ ↑↓ navigate ^k actions
192
192
 
193
193
  "
194
194
  `)
@@ -0,0 +1,54 @@
1
+ // Example: Chart tooltip showcase. Hover over any bar, segment, or data point
2
+ // to see an absolute-positioned tooltip showing the label and value.
3
+ // Demonstrates tooltips on BarGraph, Graph, BarChart, and HorizontalBarGraph.
4
+
5
+ import React from 'react'
6
+ import { Detail, BarGraph, Graph, BarChart, HorizontalBarGraph, Row } from 'termcast'
7
+ import { renderWithProviders } from '../utils'
8
+
9
+ const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
10
+ const values1 = [40, 30, 25, 15, 50, 40]
11
+ const values2 = [20, 25, 10, 10, 25, 20]
12
+
13
+ function ChartTooltipsExample() {
14
+ return (
15
+ <Detail
16
+ navigationTitle="Chart Tooltips"
17
+ markdown={[
18
+ '# Chart Tooltips',
19
+ '',
20
+ 'Hover over any chart element to see a tooltip with the data label and value.',
21
+ 'Works on BarGraph, Graph, BarChart, and HorizontalBarGraph.',
22
+ ].join('\n')}
23
+ metadata={
24
+ <Detail.Metadata>
25
+ <Detail.Metadata.Label title="BarGraph (vertical stacked)" />
26
+ <BarGraph height={10} labels={days}>
27
+ <BarGraph.Series data={values1} title="Direct" />
28
+ <BarGraph.Series data={values2} title="Referral" />
29
+ </BarGraph>
30
+ <Detail.Metadata.Separator />
31
+ <Detail.Metadata.Label title="Graph (line chart)" />
32
+ <Graph height={8} xLabels={days}>
33
+ <Graph.Line data={values1} title="Traffic" />
34
+ </Graph>
35
+ <Detail.Metadata.Separator />
36
+ <Detail.Metadata.Label title="BarChart (horizontal segments)" />
37
+ <BarChart>
38
+ <BarChart.Segment value={60} label="Spent" />
39
+ <BarChart.Segment value={25} label="Remaining" />
40
+ <BarChart.Segment value={15} label="Reserved" />
41
+ </BarChart>
42
+ <Detail.Metadata.Separator />
43
+ <Detail.Metadata.Label title="HorizontalBarGraph (stacked rows)" />
44
+ <HorizontalBarGraph labels={days.slice(0, 4)}>
45
+ <HorizontalBarGraph.Series data={[40, 30, 25, 15]} title="Organic" />
46
+ <HorizontalBarGraph.Series data={[20, 25, 10, 10]} title="Paid" />
47
+ </HorizontalBarGraph>
48
+ </Detail.Metadata>
49
+ }
50
+ />
51
+ )
52
+ }
53
+
54
+ void renderWithProviders(<ChartTooltipsExample />)
@@ -221,7 +221,7 @@ test('form date picker selection with space and enter', async () => {
221
221
  ◇ Date of Birth
222
222
 
223
223
  │ ← 2026 →
224
- │ ← May
224
+ │ ← June
225
225
 
226
226
 
227
227
  ctrl ↵ submit tab navigate ^k actions
@@ -279,7 +279,7 @@ test('form date picker selection with space and enter', async () => {
279
279
  ◇ Date of Birth
280
280
 
281
281
  │ ← 2026 →
282
- │ ← May
282
+ │ ← June
283
283
 
284
284
 
285
285
  ctrl ↵ submit tab navigate ^k actions
@@ -338,7 +338,7 @@ test('form date picker selection with space and enter', async () => {
338
338
  ◇ Date of Birth
339
339
 
340
340
  │ ← 2026 →
341
- │ ← May
341
+ │ ← June
342
342
 
343
343
 
344
344
  ctrl ↵ submit tab navigate ^k actions
@@ -413,7 +413,7 @@ test('form dropdown navigation', async () => {
413
413
  ◇ Date of Birth
414
414
 
415
415
  │ ← 2026 →
416
- │ ← May
416
+ │ ← June
417
417
 
418
418
 
419
419
  ctrl ↵ submit tab navigate ^k actions
@@ -471,7 +471,7 @@ test('form dropdown navigation', async () => {
471
471
  ◇ Date of Birth
472
472
 
473
473
  │ ← 2026 →
474
- │ ← May
474
+ │ ← June
475
475
 
476
476
 
477
477
  ctrl ↵ submit tab navigate ^k actions
@@ -531,7 +531,7 @@ test('form dropdown navigation', async () => {
531
531
  ◇ Date of Birth
532
532
 
533
533
  │ ← 2026 →
534
- │ ← May
534
+ │ ← June
535
535
 
536
536
 
537
537
  ctrl ↵ submit tab navigate ^k actions
@@ -589,7 +589,7 @@ test('form dropdown navigation', async () => {
589
589
  ◇ Date of Birth
590
590
 
591
591
  │ ← 2026 →
592
- │ ← May
592
+ │ ← June
593
593
 
594
594
 
595
595
  ctrl ↵ submit tab navigate ^k actions
@@ -763,7 +763,7 @@ test('arrow down from checkbox to dropdown lands on first item', async () => {
763
763
  ◇ Date of Birth
764
764
 
765
765
  │ ← 2026 →
766
- │ ← May
766
+ │ ← June
767
767
 
768
768
 
769
769
  ctrl ↵ submit tab navigate ^k actions