@visulima/tui-kit 0.0.0 → 1.0.1
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/CHANGELOG.md +13 -0
- package/LICENSE.md +2095 -0
- package/README.md +115 -29
- package/dist/accordion.d.ts +52 -0
- package/dist/accordion.js +1 -0
- package/dist/alert.d.ts +22 -0
- package/dist/alert.js +1 -0
- package/dist/animate-presence.d.ts +16 -0
- package/dist/animate-presence.js +1 -0
- package/dist/approval-prompt.d.ts +55 -0
- package/dist/approval-prompt.js +1 -0
- package/dist/area-chart.d.ts +56 -0
- package/dist/area-chart.js +1 -0
- package/dist/badge.d.ts +19 -0
- package/dist/badge.js +1 -0
- package/dist/bar-chart.d.ts +58 -0
- package/dist/bar-chart.js +1 -0
- package/dist/big-text.d.ts +71 -0
- package/dist/big-text.js +1 -0
- package/dist/blink-dot.d.ts +31 -0
- package/dist/blink-dot.js +1 -0
- package/dist/breadcrumb.d.ts +29 -0
- package/dist/breadcrumb.js +1 -0
- package/dist/button.d.ts +38 -0
- package/dist/button.js +1 -0
- package/dist/calendar.d.ts +60 -0
- package/dist/calendar.js +1 -0
- package/dist/card.d.ts +47 -0
- package/dist/card.js +1 -0
- package/dist/checkbox-group.d.ts +50 -0
- package/dist/checkbox-group.js +1 -0
- package/dist/checkbox.d.ts +40 -0
- package/dist/checkbox.js +1 -0
- package/dist/code.d.ts +41 -0
- package/dist/code.js +2 -0
- package/dist/collapsible.d.ts +44 -0
- package/dist/collapsible.js +1 -0
- package/dist/color-picker.d.ts +59 -0
- package/dist/color-picker.js +1 -0
- package/dist/command-block.d.ts +39 -0
- package/dist/command-block.js +4 -0
- package/dist/command-palette.d.ts +73 -0
- package/dist/command-palette.js +1 -0
- package/dist/confirm-dialog.d.ts +63 -0
- package/dist/confirm-dialog.js +1 -0
- package/dist/confirm-input.d.ts +32 -0
- package/dist/confirm-input.js +1 -0
- package/dist/console-overlay.d.ts +47 -0
- package/dist/console-overlay.js +1 -0
- package/dist/content-switcher.d.ts +49 -0
- package/dist/content-switcher.js +1 -0
- package/dist/controlled-scroll-view.d.ts +3 -0
- package/dist/controlled-scroll-view.js +1 -0
- package/dist/data-grid.d.ts +43 -0
- package/dist/data-grid.js +1 -0
- package/dist/date-picker.d.ts +54 -0
- package/dist/date-picker.js +1 -0
- package/dist/definition-list.d.ts +38 -0
- package/dist/definition-list.js +1 -0
- package/dist/dialog.d.ts +94 -0
- package/dist/dialog.js +1 -0
- package/dist/diff-view.d.ts +72 -0
- package/dist/diff-view.js +1 -0
- package/dist/digits.d.ts +26 -0
- package/dist/digits.js +1 -0
- package/dist/directory-tree.d.ts +44 -0
- package/dist/directory-tree.js +1 -0
- package/dist/divider.d.ts +41 -0
- package/dist/divider.js +1 -0
- package/dist/drawer.d.ts +45 -0
- package/dist/drawer.js +1 -0
- package/dist/file-change.d.ts +34 -0
- package/dist/file-change.js +1 -0
- package/dist/file-picker-entry.d.ts +2 -0
- package/dist/file-picker-entry.js +1 -0
- package/dist/form.d.ts +74 -0
- package/dist/form.js +1 -0
- package/dist/gauge.d.ts +76 -0
- package/dist/gauge.js +1 -0
- package/dist/gradient.d.ts +38 -0
- package/dist/gradient.js +1 -0
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +1 -0
- package/dist/heading.d.ts +28 -0
- package/dist/heading.js +1 -0
- package/dist/heatmap.d.ts +52 -0
- package/dist/heatmap.js +1 -0
- package/dist/help.d.ts +45 -0
- package/dist/help.js +1 -0
- package/dist/histogram.d.ts +52 -0
- package/dist/histogram.js +1 -0
- package/dist/image.d.ts +23 -0
- package/dist/image.js +1 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +1 -0
- package/dist/json.d.ts +51 -0
- package/dist/json.js +1 -0
- package/dist/kbd.d.ts +27 -0
- package/dist/kbd.js +1 -0
- package/dist/line-chart.d.ts +100 -0
- package/dist/line-chart.js +1 -0
- package/dist/link.d.ts +34 -0
- package/dist/link.js +1 -0
- package/dist/loading-indicator.d.ts +25 -0
- package/dist/loading-indicator.js +1 -0
- package/dist/log.d.ts +33 -0
- package/dist/log.js +1 -0
- package/dist/markdown.d.ts +35 -0
- package/dist/markdown.js +1 -0
- package/dist/masked-input.d.ts +42 -0
- package/dist/masked-input.js +1 -0
- package/dist/menu.d.ts +70 -0
- package/dist/menu.js +1 -0
- package/dist/message-bubble.d.ts +36 -0
- package/dist/message-bubble.js +1 -0
- package/dist/model-badge.d.ts +33 -0
- package/dist/model-badge.js +1 -0
- package/dist/model-selector.d.ts +45 -0
- package/dist/model-selector.js +1 -0
- package/dist/multi-progress.d.ts +48 -0
- package/dist/multi-progress.js +1 -0
- package/dist/multi-select.d.ts +55 -0
- package/dist/multi-select.js +1 -0
- package/dist/notification-badge.d.ts +40 -0
- package/dist/notification-badge.js +1 -0
- package/dist/number-input.d.ts +71 -0
- package/dist/number-input.js +1 -0
- package/dist/operation-tree.d.ts +46 -0
- package/dist/operation-tree.js +1 -0
- package/dist/option-list.d.ts +41 -0
- package/dist/option-list.js +1 -0
- package/dist/ordered-list.d.ts +22 -0
- package/dist/ordered-list.js +1 -0
- package/dist/packem_shared/ControlledScrollView-BzlbIUph.js +1 -0
- package/dist/packem_shared/FilePicker-V5H2fb-v.js +1 -0
- package/dist/packem_shared/ScrollBar-C9i3z3Ft.js +1 -0
- package/dist/packem_shared/ScrollBarBox-DZEC8YnV.js +1 -0
- package/dist/packem_shared/ScrollList-Djtb2vVy.js +1 -0
- package/dist/packem_shared/ScrollView-ESxpl6Hy.js +1 -0
- package/dist/packem_shared/TreeNodeMap-cJlYqckB.js +1 -0
- package/dist/packem_shared/TreeView-q0l446NV.js +1 -0
- package/dist/packem_shared/controlled-scroll-view-DerVKKv6.js +1 -0
- package/dist/packem_shared/controlled-scroll-view.d-D3kZZzjI.d.ts +43 -0
- package/dist/packem_shared/file-picker.d-CH7pccC7.d.ts +92 -0
- package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
- package/dist/packem_shared/scroll-bar-box.d-CpTYEcTZ.d.ts +13 -0
- package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
- package/dist/packem_shared/scroll-list.d-CPCF0RwC.d.ts +12 -0
- package/dist/packem_shared/scroll-view.d-GZcgAHl4.d.ts +98 -0
- package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
- package/dist/packem_shared/use-tree-view.d-DeMQ6G2r.d.ts +314 -0
- package/dist/packem_shared/useTreeView-D0JEqT6W.js +1 -0
- package/dist/packem_shared/useTreeViewState-BiTJakkh.js +1 -0
- package/dist/packem_shared/variant-config-BJqpNo7H.js +1 -0
- package/dist/packem_shared/variant-config.d-D5NnrrQK.d.ts +2 -0
- package/dist/paginator.d.ts +59 -0
- package/dist/paginator.js +1 -0
- package/dist/paragraph.d.ts +32 -0
- package/dist/paragraph.js +1 -0
- package/dist/path-input.d.ts +56 -0
- package/dist/path-input.js +1 -0
- package/dist/pie-chart.d.ts +44 -0
- package/dist/pie-chart.js +1 -0
- package/dist/placeholder.d.ts +47 -0
- package/dist/placeholder.js +1 -0
- package/dist/popover.d.ts +39 -0
- package/dist/popover.js +1 -0
- package/dist/progress-bar.d.ts +43 -0
- package/dist/progress-bar.js +1 -0
- package/dist/progress-circle.d.ts +50 -0
- package/dist/progress-circle.js +1 -0
- package/dist/qr-code.d.ts +39 -0
- package/dist/qr-code.js +1 -0
- package/dist/r/accordion.json +24 -0
- package/dist/r/alert.json +24 -0
- package/dist/r/animate-presence.json +22 -0
- package/dist/r/approval-prompt.json +24 -0
- package/dist/r/area-chart.json +27 -0
- package/dist/r/badge.json +24 -0
- package/dist/r/bar-chart.json +24 -0
- package/dist/r/big-text.json +23 -0
- package/dist/r/blink-dot.json +24 -0
- package/dist/r/breadcrumb.json +24 -0
- package/dist/r/button.json +24 -0
- package/dist/r/calendar.json +24 -0
- package/dist/r/card.json +26 -0
- package/dist/r/chart-utils.json +24 -0
- package/dist/r/checkbox-group.json +24 -0
- package/dist/r/checkbox.json +24 -0
- package/dist/r/code.json +23 -0
- package/dist/r/collapsible.json +24 -0
- package/dist/r/color-picker.json +24 -0
- package/dist/r/command-block.json +26 -0
- package/dist/r/command-palette.json +24 -0
- package/dist/r/confirm-dialog.json +24 -0
- package/dist/r/confirm-input.json +22 -0
- package/dist/r/console-overlay.json +22 -0
- package/dist/r/content-switcher.json +24 -0
- package/dist/r/controlled-scroll-view.json +24 -0
- package/dist/r/data-grid.json +24 -0
- package/dist/r/date-picker.json +26 -0
- package/dist/r/definition-list.json +24 -0
- package/dist/r/dialog.json +24 -0
- package/dist/r/diff-view.json +24 -0
- package/dist/r/digits.json +24 -0
- package/dist/r/directory-tree.json +24 -0
- package/dist/r/divider.json +24 -0
- package/dist/r/drawer.json +24 -0
- package/dist/r/file-change.json +24 -0
- package/dist/r/file-picker.json +44 -0
- package/dist/r/form.json +24 -0
- package/dist/r/gauge.json +24 -0
- package/dist/r/gradient.json +24 -0
- package/dist/r/grid.json +22 -0
- package/dist/r/heading.json +24 -0
- package/dist/r/heatmap.json +24 -0
- package/dist/r/help.json +22 -0
- package/dist/r/histogram.json +26 -0
- package/dist/r/image.json +24 -0
- package/dist/r/json.json +24 -0
- package/dist/r/kbd.json +24 -0
- package/dist/r/line-chart.json +26 -0
- package/dist/r/link.json +23 -0
- package/dist/r/loading-indicator.json +27 -0
- package/dist/r/log.json +24 -0
- package/dist/r/markdown.json +29 -0
- package/dist/r/masked-input.json +22 -0
- package/dist/r/menu.json +24 -0
- package/dist/r/message-bubble.json +24 -0
- package/dist/r/model-badge.json +24 -0
- package/dist/r/model-selector.json +24 -0
- package/dist/r/multi-progress.json +24 -0
- package/dist/r/multi-select.json +24 -0
- package/dist/r/notification-badge.json +24 -0
- package/dist/r/number-input.json +24 -0
- package/dist/r/operation-tree.json +26 -0
- package/dist/r/option-list.json +24 -0
- package/dist/r/ordered-list.json +22 -0
- package/dist/r/paginator.json +22 -0
- package/dist/r/paragraph.json +24 -0
- package/dist/r/path-input.json +26 -0
- package/dist/r/pie-chart.json +24 -0
- package/dist/r/placeholder.json +24 -0
- package/dist/r/popover.json +24 -0
- package/dist/r/progress-bar.json +22 -0
- package/dist/r/progress-circle.json +24 -0
- package/dist/r/qr-code.json +24 -0
- package/dist/r/radio-group.json +24 -0
- package/dist/r/registry.json +1051 -0
- package/dist/r/scatter-plot.json +27 -0
- package/dist/r/scroll-bar-box.json +24 -0
- package/dist/r/scroll-bar.json +24 -0
- package/dist/r/scroll-list.json +24 -0
- package/dist/r/scroll-view.json +24 -0
- package/dist/r/scroll.json +58 -0
- package/dist/r/search-input.json +26 -0
- package/dist/r/select-input-indicator.json +24 -0
- package/dist/r/select-input-item.json +24 -0
- package/dist/r/select-input.json +28 -0
- package/dist/r/shimmer-text.json +24 -0
- package/dist/r/sidebar.json +24 -0
- package/dist/r/slider.json +24 -0
- package/dist/r/sparkline.json +24 -0
- package/dist/r/spinner.json +23 -0
- package/dist/r/status-line.json +24 -0
- package/dist/r/status-message.json +24 -0
- package/dist/r/stepper.json +24 -0
- package/dist/r/stopwatch.json +22 -0
- package/dist/r/streaming-text.json +24 -0
- package/dist/r/switch.json +24 -0
- package/dist/r/tab.json +22 -0
- package/dist/r/table.json +23 -0
- package/dist/r/tabs.json +26 -0
- package/dist/r/tag-input.json +24 -0
- package/dist/r/tag.json +24 -0
- package/dist/r/text-input.json +22 -0
- package/dist/r/textarea.json +22 -0
- package/dist/r/thinking-block.json +24 -0
- package/dist/r/time-picker.json +24 -0
- package/dist/r/timer.json +22 -0
- package/dist/r/toast.json +24 -0
- package/dist/r/token-usage.json +24 -0
- package/dist/r/tool-call.json +24 -0
- package/dist/r/tooltip.json +24 -0
- package/dist/r/transition.json +22 -0
- package/dist/r/tree-select.json +24 -0
- package/dist/r/tree-view.json +64 -0
- package/dist/r/unordered-list.json +22 -0
- package/dist/r/use-linked-scroll.json +24 -0
- package/dist/r/variant-config.json +22 -0
- package/dist/radio-group.d.ts +63 -0
- package/dist/radio-group.js +1 -0
- package/dist/scatter-plot.d.ts +24 -0
- package/dist/scatter-plot.js +1 -0
- package/dist/scroll-bar-box.d.ts +3 -0
- package/dist/scroll-bar-box.js +1 -0
- package/dist/scroll-bar.d.ts +3 -0
- package/dist/scroll-bar.js +1 -0
- package/dist/scroll-list.d.ts +4 -0
- package/dist/scroll-list.js +1 -0
- package/dist/scroll-view.d.ts +3 -0
- package/dist/scroll-view.js +1 -0
- package/dist/search-input.d.ts +46 -0
- package/dist/search-input.js +1 -0
- package/dist/select-input-indicator.d.ts +28 -0
- package/dist/select-input-indicator.js +1 -0
- package/dist/select-input-item.d.ts +35 -0
- package/dist/select-input-item.js +1 -0
- package/dist/select-input.d.ts +97 -0
- package/dist/select-input.js +1 -0
- package/dist/shimmer-text.d.ts +41 -0
- package/dist/shimmer-text.js +1 -0
- package/dist/sidebar.d.ts +40 -0
- package/dist/sidebar.js +1 -0
- package/dist/slider.d.ts +87 -0
- package/dist/slider.js +1 -0
- package/dist/sparkline.d.ts +36 -0
- package/dist/sparkline.js +1 -0
- package/dist/spinner.d.ts +15 -0
- package/dist/spinner.js +1 -0
- package/dist/status-line.d.ts +31 -0
- package/dist/status-line.js +1 -0
- package/dist/status-message.d.ts +18 -0
- package/dist/status-message.js +1 -0
- package/dist/stepper.d.ts +42 -0
- package/dist/stepper.js +1 -0
- package/dist/stopwatch.d.ts +42 -0
- package/dist/stopwatch.js +1 -0
- package/dist/streaming-text.d.ts +39 -0
- package/dist/streaming-text.js +1 -0
- package/dist/switch.d.ts +46 -0
- package/dist/switch.js +1 -0
- package/dist/tab.d.ts +17 -0
- package/dist/tab.js +1 -0
- package/dist/table.d.ts +118 -0
- package/dist/table.js +1 -0
- package/dist/tabs.d.ts +96 -0
- package/dist/tabs.js +1 -0
- package/dist/tag-input.d.ts +59 -0
- package/dist/tag-input.js +1 -0
- package/dist/tag.d.ts +30 -0
- package/dist/tag.js +1 -0
- package/dist/text-input.d.ts +40 -0
- package/dist/text-input.js +1 -0
- package/dist/textarea.d.ts +57 -0
- package/dist/textarea.js +2 -0
- package/dist/thinking-block.d.ts +48 -0
- package/dist/thinking-block.js +1 -0
- package/dist/time-picker.d.ts +57 -0
- package/dist/time-picker.js +1 -0
- package/dist/timer.d.ts +50 -0
- package/dist/timer.js +1 -0
- package/dist/toast.d.ts +41 -0
- package/dist/toast.js +1 -0
- package/dist/token-usage.d.ts +43 -0
- package/dist/token-usage.js +1 -0
- package/dist/tool-call.d.ts +28 -0
- package/dist/tool-call.js +1 -0
- package/dist/tooltip.d.ts +37 -0
- package/dist/tooltip.js +1 -0
- package/dist/transition.d.ts +66 -0
- package/dist/transition.js +1 -0
- package/dist/tree-select.d.ts +55 -0
- package/dist/tree-select.js +1 -0
- package/dist/tree-view-entry.d.ts +4 -0
- package/dist/tree-view-entry.js +1 -0
- package/dist/unordered-list.d.ts +27 -0
- package/dist/unordered-list.js +1 -0
- package/dist/use-linked-scroll.d.ts +42 -0
- package/dist/use-linked-scroll.js +1 -0
- package/package.json +553 -7
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scatter-plot",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scatter Plot",
|
|
6
|
+
"description": "Scatter Plot component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"https://visulima.com/r/chart-utils.json",
|
|
17
|
+
"https://visulima.com/r/line-chart.json"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { CanvasContext } from \"@visulima/tui/canvas\";\nimport Box from \"@visulima/tui/components/box\";\nimport Canvas from \"@visulima/tui/components/canvas\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport { computeExtents, DEFAULT_CHART_PALETTE, pickSeriesColor, toPoints } from \"@/lib/chart-utils\";\nimport type { LineSeries } from \"@/components/ui/line-chart\";\nimport { drawSeriesOnCanvas } from \"@/components/ui/line-chart\";\n\nexport type Props = {\n readonly axisColor?: LiteralUnion<AnsiColors, string>;\n readonly height?: number;\n readonly maxX?: number;\n readonly maxY?: number;\n readonly minX?: number;\n readonly minY?: number;\n readonly palette?: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n readonly series: ReadonlyArray<LineSeries>;\n readonly showLegend?: boolean;\n readonly width?: number;\n};\n\nconst DEFAULT_PALETTE = DEFAULT_CHART_PALETTE;\n\n/**\n * Scatter plot rendered on a braille pixel grid. Each sample lights one\n * sub-cell pixel; overlapping points merge into denser braille glyphs.\n * @returns A `ReactElement` containing the plot and an optional legend.\n */\nexport default function ScatterPlot({\n axisColor = \"gray\",\n height = 10,\n maxX,\n maxY,\n minX,\n minY,\n palette = DEFAULT_PALETTE,\n series,\n showLegend = true,\n width = 40,\n}: Props): ReactElement {\n const config = useMemo(() => {\n const seriesList = series.map((input, index) => {\n return {\n color: pickSeriesColor(input, index, palette),\n points: toPoints(input.data),\n series: input,\n };\n });\n\n const extents = computeExtents(seriesList);\n\n return {\n axisColor,\n palette,\n seriesList,\n xMax: maxX ?? (extents.xMax === Number.NEGATIVE_INFINITY ? 1 : extents.xMax),\n xMin: minX ?? (extents.xMin === Number.POSITIVE_INFINITY ? 0 : extents.xMin),\n yMax: maxY ?? (extents.yMax === Number.NEGATIVE_INFINITY ? 1 : extents.yMax),\n yMin: minY ?? (extents.yMin === Number.POSITIVE_INFINITY ? 0 : extents.yMin),\n };\n }, [series, axisColor, palette, minX, maxX, minY, maxY]);\n\n return (\n <Box flexDirection=\"column\">\n <Canvas\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- canvas re-renders on `version` change, not draw identity\n draw={(context: CanvasContext) => {\n drawSeriesOnCanvas(context, \"scatter\", config);\n }}\n height={height}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[series, width, height, minX, maxX, minY, maxY, axisColor, palette]}\n width={width}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showLegend ? (\n <Box gap={2} marginTop={1}>\n {config.seriesList.map(({ color, series: input }, index) => (\n // Composite key: series index plus label so two\n // series sharing a label don't collide.\n // eslint-disable-next-line react-x/no-array-index-key -- series index is stable for the render\n <Box gap={1} key={`${index}:${input.label ?? \"\"}`}>\n <Text color={color}>●</Text>\n <Text>{input.label ?? `Series ${index + 1}`}</Text>\n </Box>\n ))}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport { ScatterPlot };\nexport type { Props as ScatterPlotProps };\n",
|
|
22
|
+
"path": "registry/ui/scatter-plot.tsx",
|
|
23
|
+
"target": "components/ui/scatter-plot.tsx",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scroll-bar-box",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scroll Bar Box",
|
|
6
|
+
"description": "Scroll Bar Box component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "export type { ScrollBarBoxProps } from \"@/components/ui/scroll/scroll-bar-box\";\nexport { ScrollBarBox } from \"@/components/ui/scroll/scroll-bar-box\";\n",
|
|
19
|
+
"path": "registry/ui/scroll-bar-box.ts",
|
|
20
|
+
"target": "components/ui/scroll-bar-box.ts",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scroll-bar",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scroll Bar",
|
|
6
|
+
"description": "Scroll Bar component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "export type { ScrollBarPlacement, ScrollBarProps, ScrollBarStyle } from \"@/components/ui/scroll/scroll-bar\";\nexport { ScrollBar } from \"@/components/ui/scroll/scroll-bar\";\n",
|
|
19
|
+
"path": "registry/ui/scroll-bar.ts",
|
|
20
|
+
"target": "components/ui/scroll-bar.ts",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scroll-list",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scroll List",
|
|
6
|
+
"description": "Scroll List component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "export type { ScrollAlignment, ScrollListProps, ScrollListRef } from \"@/components/ui/scroll/scroll-list\";\nexport { ScrollList } from \"@/components/ui/scroll/scroll-list\";\n",
|
|
19
|
+
"path": "registry/ui/scroll-list.ts",
|
|
20
|
+
"target": "components/ui/scroll-list.ts",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scroll-view",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scroll View",
|
|
6
|
+
"description": "Scroll View component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "export type { ScrollViewProps, ScrollViewRef } from \"@/components/ui/scroll/scroll-view\";\nexport { ScrollView } from \"@/components/ui/scroll/scroll-view\";\n",
|
|
19
|
+
"path": "registry/ui/scroll-view.ts",
|
|
20
|
+
"target": "components/ui/scroll-view.ts",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "scroll",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Scroll",
|
|
6
|
+
"description": "Scroll component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"content": "import { measureElement } from \"@visulima/tui\";\nimport type { Props as BoxProps } from \"@visulima/tui/components/box\";\nimport Box from \"@visulima/tui/components/box\";\nimport type { DOMElement } from \"@visulima/tui/dom\";\nimport type React from \"react\";\nimport type { ReactNode, Ref } from \"react\";\nimport { Fragment, isValidElement, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\n\nimport useStateRef from \"./use-state-ref\";\n\nconst MeasurableItem = ({\n children,\n index,\n measureKey,\n onMeasure,\n width,\n}: {\n children: ReactNode;\n index: number;\n measureKey?: number;\n onMeasure: (index: number, height: number) => void;\n width: number;\n}): React.JSX.Element => {\n const ref = useRef<DOMElement>(null);\n\n useLayoutEffect(() => {\n if (ref.current) {\n const { height } = measureElement(ref.current);\n\n onMeasure(index, height);\n }\n }, [index, onMeasure, width, measureKey, children]);\n\n return (\n <Box flexDirection=\"column\" flexShrink={0} ref={ref} width=\"100%\">\n {children}\n </Box>\n );\n};\n\nconst toChildArray = (children: ReactNode): ReactNode[] => {\n if (children == null) {\n return [];\n }\n\n if (Array.isArray(children)) {\n const result: ReactNode[] = [];\n\n // eslint-disable-next-line unicorn/no-for-loop\n for (let index = 0; index < children.length; index += 1) {\n const child = children[index] as ReactNode;\n\n if (isValidElement(child) && child.type === Fragment) {\n result.push(...toChildArray((child.props as { children?: ReactNode }).children));\n } else {\n result.push(child);\n }\n }\n\n return result;\n }\n\n if (isValidElement(children) && children.type === Fragment) {\n return toChildArray((children.props as { children?: ReactNode }).children);\n }\n\n return [children];\n};\n\nexport interface ControlledScrollViewProps extends BoxProps {\n children?: ReactNode;\n debug?: boolean;\n onContentHeightChange?: (height: number, previousHeight: number) => void;\n onItemHeightChange?: (index: number, height: number, previousHeight: number) => void;\n onViewportSizeChange?: (size: { height: number; width: number }, previousSize: { height: number; width: number }) => void;\n\n /**\n * Number of extra items to render above/below the viewport when virtualized.\n * @default 3\n */\n overscan?: number;\n scrollOffset: number;\n\n /**\n * Only render items visible in the viewport plus overscan.\n * Items must be measured first (first render always renders all items).\n * @default false\n */\n virtualize?: boolean;\n}\n\nexport interface ControlledScrollViewRef {\n getBottomOffset: () => number;\n getContentHeight: () => number;\n getItemHeight: (index: number) => number;\n getItemPosition: (index: number) => { height: number; top: number } | null;\n getViewportHeight: () => number;\n remeasure: () => void;\n remeasureItem: (index: number) => void;\n}\n\nexport const ControlledScrollView = ({\n children,\n debug = false,\n onContentHeightChange,\n onItemHeightChange,\n onViewportSizeChange,\n overscan = 3,\n ref,\n scrollOffset,\n virtualize = false,\n ...boxProps\n}: ControlledScrollViewProps & { ref?: Ref<ControlledScrollViewRef> }): React.JSX.Element => {\n const hasMeasuredRef = useRef(false);\n const [viewportSize, setViewportSize, getViewportSize] = useStateRef({\n height: 0,\n width: 0,\n });\n const [contentHeight, setContentHeight, getContentHeight] = useStateRef(0);\n const [itemMeasureKeys, setItemMeasureKeys] = useState<Record<number, number>>({});\n\n const viewportRef = useRef<DOMElement>(null);\n const previousContentHeightRef = useRef(0);\n\n useLayoutEffect(() => {\n if (contentHeight !== previousContentHeightRef.current) {\n onContentHeightChange?.(contentHeight, previousContentHeightRef.current);\n previousContentHeightRef.current = contentHeight;\n }\n }, [contentHeight, onContentHeightChange]);\n\n const itemHeightsRef = useRef<Record<string | number, number>>({});\n const itemKeysRef = useRef<(string | number)[]>([]);\n const itemOffsetsRef = useRef<number[]>([]);\n const firstInvalidOffsetIndexRef = useRef(0);\n\n const handleItemMeasure = useCallback(\n (index: number, height: number) => {\n const key = itemKeysRef.current[index] ?? index;\n\n if (itemHeightsRef.current[key] !== height) {\n const previousHeight = itemHeightsRef.current[key] ?? 0;\n\n itemHeightsRef.current = {\n ...itemHeightsRef.current,\n [key]: height,\n };\n\n let newTotalHeight = 0;\n\n for (let i = 0; i < itemKeysRef.current.length; i += 1) {\n const itemKey = itemKeysRef.current[i];\n\n if (itemKey != null) {\n newTotalHeight += itemHeightsRef.current[itemKey] ?? 0;\n }\n }\n\n const currentHeight = getContentHeight();\n\n if (newTotalHeight !== currentHeight) {\n setContentHeight(newTotalHeight);\n }\n\n onItemHeightChange?.(index, height, previousHeight);\n\n firstInvalidOffsetIndexRef.current = Math.min(firstInvalidOffsetIndexRef.current, index + 1);\n }\n },\n [onItemHeightChange, getContentHeight, setContentHeight],\n );\n\n const measureViewport = useCallback(() => {\n if (viewportRef.current) {\n const { height, width } = measureElement(viewportRef.current);\n const currentSize = getViewportSize();\n\n if (width !== currentSize.width || height !== currentSize.height) {\n onViewportSizeChange?.({ height, width }, currentSize);\n setViewportSize({ height, width });\n }\n }\n }, [viewportRef, onViewportSizeChange, getViewportSize, setViewportSize]);\n\n useLayoutEffect(() => {\n measureViewport();\n });\n\n const previousChildrenRef = useRef<typeof children>(null);\n\n if (previousChildrenRef.current !== children) {\n previousChildrenRef.current = children;\n\n const newItemKeys: (string | number)[] = [];\n const newItemHeights: Record<string | number, number> = {};\n\n const childArray = toChildArray(children);\n\n childArray.forEach((child: ReactNode, index: number) => {\n if (!child) {\n return;\n }\n\n const key = isValidElement(child) ? child.key : null;\n const effectiveKey = key ?? index;\n\n newItemKeys[index] = effectiveKey;\n const itemHeight = itemHeightsRef.current[effectiveKey] ?? 0;\n\n newItemHeights[effectiveKey] = itemHeight;\n });\n\n itemHeightsRef.current = newItemHeights;\n itemKeysRef.current = newItemKeys;\n itemOffsetsRef.current = Array.from<number>({ length: newItemKeys.length }).fill(0);\n firstInvalidOffsetIndexRef.current = 0;\n\n let newTotalHeight = 0;\n\n newItemKeys.forEach((itemKey) => {\n newTotalHeight += newItemHeights[itemKey] ?? 0;\n });\n\n const currentHeight = getContentHeight();\n\n if (newTotalHeight !== currentHeight) {\n setContentHeight(newTotalHeight);\n }\n }\n\n useImperativeHandle(ref, () => {\n return {\n getBottomOffset: () => Math.max(0, getContentHeight() - getViewportSize().height),\n getContentHeight,\n getItemHeight: (index: number) => {\n const key = itemKeysRef.current[index] ?? index;\n\n return itemHeightsRef.current[key] ?? 0;\n },\n getItemPosition: (index: number) => {\n if (index < 0 || index >= itemKeysRef.current.length) {\n return null;\n }\n\n if (index >= firstInvalidOffsetIndexRef.current) {\n let currentOffset = 0;\n let startIndex = 0;\n\n if (firstInvalidOffsetIndexRef.current > 0) {\n startIndex = firstInvalidOffsetIndexRef.current;\n const previousIndex = startIndex - 1;\n const previousKey = itemKeysRef.current[previousIndex] ?? previousIndex;\n const previousHeight = itemHeightsRef.current[previousKey] ?? 0;\n\n currentOffset = (itemOffsetsRef.current[previousIndex] ?? 0) + previousHeight;\n }\n\n for (let i = startIndex; i <= index; i += 1) {\n itemOffsetsRef.current[i] = currentOffset;\n const key = itemKeysRef.current[i] ?? i;\n const height = itemHeightsRef.current[key] ?? 0;\n\n currentOffset += height;\n }\n\n firstInvalidOffsetIndexRef.current = index + 1;\n }\n\n const top = itemOffsetsRef.current[index] ?? 0;\n const key = itemKeysRef.current[index] ?? index;\n const height = itemHeightsRef.current[key] ?? 0;\n\n return { height, top };\n },\n getViewportHeight: () => getViewportSize().height,\n remeasure: measureViewport,\n remeasureItem: (index: number) => {\n setItemMeasureKeys((previous) => {\n return {\n ...previous,\n [index]: (previous[index] ?? 0) + 1,\n };\n });\n },\n };\n }, [measureViewport, getContentHeight, getViewportSize, setItemMeasureKeys]);\n\n const childArray = toChildArray(children);\n\n // Track when first measurement pass is complete\n if (!hasMeasuredRef.current && contentHeight > 0) {\n hasMeasuredRef.current = true;\n }\n\n // Virtualization: compute visible range\n let firstRenderIndex = 0;\n let lastRenderIndex = childArray.length - 1;\n let aboveSpacerHeight = 0;\n let belowSpacerHeight = 0;\n // Only virtualize if all current items have been measured (height > 0).\n // New items added dynamically will have height 0 until measured.\n const allItemsMeasured\n = childArray.length > 0\n && childArray.every((_: ReactNode, index: number) => {\n const key = itemKeysRef.current[index] ?? index;\n\n return (itemHeightsRef.current[key] ?? 0) > 0;\n });\n const shouldVirtualize = virtualize && hasMeasuredRef.current && allItemsMeasured;\n\n if (shouldVirtualize) {\n // Ensure all offsets are computed\n const totalItems = itemKeysRef.current.length;\n\n if (firstInvalidOffsetIndexRef.current < totalItems) {\n let currentOffset = 0;\n let startIndex = 0;\n\n if (firstInvalidOffsetIndexRef.current > 0) {\n startIndex = firstInvalidOffsetIndexRef.current;\n const previousIndex = startIndex - 1;\n const previousKey = itemKeysRef.current[previousIndex] ?? previousIndex;\n const previousHeight = itemHeightsRef.current[previousKey] ?? 0;\n\n currentOffset = (itemOffsetsRef.current[previousIndex] ?? 0) + previousHeight;\n }\n\n for (let i = startIndex; i < totalItems; i += 1) {\n itemOffsetsRef.current[i] = currentOffset;\n const key = itemKeysRef.current[i] ?? i;\n\n currentOffset += itemHeightsRef.current[key] ?? 0;\n }\n\n firstInvalidOffsetIndexRef.current = totalItems;\n }\n\n // Find visible range\n const viewEnd = scrollOffset + viewportSize.height;\n\n firstRenderIndex = childArray.length;\n lastRenderIndex = -1;\n\n for (let i = 0; i < totalItems; i += 1) {\n const itemTop = itemOffsetsRef.current[i] ?? 0;\n const key = itemKeysRef.current[i] ?? i;\n const itemHeight = itemHeightsRef.current[key] ?? 0;\n const itemBottom = itemTop + itemHeight;\n\n if (itemBottom > scrollOffset && itemTop < viewEnd) {\n if (i < firstRenderIndex) {\n firstRenderIndex = i;\n }\n\n lastRenderIndex = i;\n }\n }\n\n // Guard: if no items are visible, reset to safe empty range\n if (firstRenderIndex > lastRenderIndex) {\n firstRenderIndex = 0;\n lastRenderIndex = -1;\n }\n\n // Apply overscan\n firstRenderIndex = Math.max(0, firstRenderIndex - overscan);\n lastRenderIndex = Math.min(childArray.length - 1, Math.max(-1, lastRenderIndex) + overscan);\n\n // Compute spacer heights\n aboveSpacerHeight = itemOffsetsRef.current[firstRenderIndex] ?? 0;\n\n if (lastRenderIndex < childArray.length - 1) {\n const lastKey = itemKeysRef.current[lastRenderIndex] ?? lastRenderIndex;\n const lastItemBottom = (itemOffsetsRef.current[lastRenderIndex] ?? 0) + (itemHeightsRef.current[lastKey] ?? 0);\n\n belowSpacerHeight = contentHeight - lastItemBottom;\n }\n }\n\n return (\n <Box {...boxProps}>\n <Box ref={viewportRef} width=\"100%\">\n <Box overflow={debug ? undefined : \"hidden\"} width=\"100%\">\n <Box flexDirection=\"column\" marginTop={-scrollOffset} width=\"100%\">\n {shouldVirtualize && aboveSpacerHeight > 0 && <Box height={aboveSpacerHeight} />}\n {childArray.map((child: ReactNode, index: number) => {\n if (!child) {\n return null;\n }\n\n if (shouldVirtualize && (index < firstRenderIndex || index > lastRenderIndex)) {\n return null;\n }\n\n return (\n <MeasurableItem\n index={index}\n // eslint-disable-next-line react-x/no-array-index-key -- virtualized opaque children have no stable identity beyond position; index is the correct key\n key={isValidElement(child) ? child.key ?? index : index}\n measureKey={itemMeasureKeys[index]}\n onMeasure={handleItemMeasure}\n width={viewportSize.width}\n >\n {child}\n </MeasurableItem>\n );\n })}\n {shouldVirtualize && belowSpacerHeight > 0 && <Box height={belowSpacerHeight} />}\n </Box>\n </Box>\n </Box>\n </Box>\n );\n};\n",
|
|
17
|
+
"path": "registry/ui/scroll/controlled-scroll-view.tsx",
|
|
18
|
+
"target": "components/ui/scroll/controlled-scroll-view.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"content": "// fallow-ignore-file unused-type -- public component API barrel; re-exports consumed by library users, not internally.\n// fallow-ignore-file unused-export -- public component API barrel; re-exports consumed by library users, not internally.\nexport type { ControlledScrollViewProps, ControlledScrollViewRef } from \"./controlled-scroll-view\";\nexport { ControlledScrollView } from \"./controlled-scroll-view\";\nexport type { ScrollBarPlacement, ScrollBarProps, ScrollBarStyle } from \"./scroll-bar\";\nexport { ScrollBar } from \"./scroll-bar\";\nexport type { ScrollBarBoxProps } from \"./scroll-bar-box\";\nexport { ScrollBarBox } from \"./scroll-bar-box\";\nexport type { ScrollAlignment, ScrollListProps, ScrollListRef } from \"./scroll-list\";\nexport { ScrollList } from \"./scroll-list\";\nexport type { ScrollViewProps, ScrollViewRef } from \"./scroll-view\";\nexport { ScrollView } from \"./scroll-view\";\n",
|
|
23
|
+
"path": "registry/ui/scroll/index.ts",
|
|
24
|
+
"target": "components/ui/scroll/index.ts",
|
|
25
|
+
"type": "registry:ui"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"content": "import type { Props as BoxProps } from \"@visulima/tui/components/box\";\nimport Box from \"@visulima/tui/components/box\";\nimport type React from \"react\";\n\nimport { ScrollBar } from \"./scroll-bar\";\n\ninterface ScrollBarBoxProps extends BoxProps {\n children?: React.ReactNode;\n contentHeight: number;\n scrollBarAutoHide?: boolean;\n scrollBarPosition?: \"left\" | \"right\";\n scrollOffset: number;\n thumbChar?: string;\n viewportHeight: number;\n}\n\nconst ScrollBarBox = ({\n borderColor,\n borderDimColor,\n borderLeftColor,\n borderLeftDimColor,\n borderRightColor,\n borderRightDimColor,\n borderStyle = \"single\",\n children,\n contentHeight,\n height,\n scrollBarAutoHide = false,\n scrollBarPosition = \"right\",\n scrollOffset,\n thumbChar,\n viewportHeight,\n ...boxProps\n}: ScrollBarBoxProps): React.JSX.Element => {\n const isLeft = scrollBarPosition === \"left\";\n const scrollBarPlacement = isLeft ? \"left\" : \"right\";\n const scrollBarColor = isLeft ? borderLeftColor ?? borderColor : borderRightColor ?? borderColor;\n const scrollBarDimColor = isLeft ? borderLeftDimColor ?? borderDimColor : borderRightDimColor ?? borderDimColor;\n\n return (\n <Box flexDirection=\"row\" height={height} {...boxProps}>\n {isLeft && (\n <ScrollBar\n autoHide={scrollBarAutoHide}\n color={scrollBarColor}\n contentHeight={contentHeight}\n dimColor={scrollBarDimColor}\n placement={scrollBarPlacement}\n scrollOffset={scrollOffset}\n style={borderStyle}\n thumbChar={thumbChar}\n viewportHeight={viewportHeight}\n />\n )}\n <Box\n borderColor={borderColor}\n borderDimColor={borderDimColor}\n borderLeft={!isLeft}\n borderRight={isLeft}\n borderStyle={borderStyle}\n flexGrow={1}\n overflow=\"hidden\"\n >\n {children}\n </Box>\n {!isLeft && (\n <ScrollBar\n autoHide={scrollBarAutoHide}\n color={scrollBarColor}\n contentHeight={contentHeight}\n dimColor={scrollBarDimColor}\n placement={scrollBarPlacement}\n scrollOffset={scrollOffset}\n style={borderStyle}\n thumbChar={thumbChar}\n viewportHeight={viewportHeight}\n />\n )}\n </Box>\n );\n};\n\nexport type { ScrollBarBoxProps };\n\nexport { ScrollBarBox };\n",
|
|
29
|
+
"path": "registry/ui/scroll/scroll-bar-box.tsx",
|
|
30
|
+
"target": "components/ui/scroll/scroll-bar-box.tsx",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"content": "import type { Props as BoxProps } from \"@visulima/tui/components/box\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport React, { useMemo } from \"react\";\n\ntype ScrollBarPlacement = \"inset\" | \"left\" | \"right\";\n\ntype ScrollBarStyle = BoxProps[\"borderStyle\"] | \"block\" | \"line\" | \"thick\" | \"dots\";\n\ninterface ScrollBarProps {\n autoHide?: boolean;\n color?: string;\n contentHeight: number;\n dimColor?: boolean;\n placement?: ScrollBarPlacement;\n scrollOffset: number;\n style?: ScrollBarStyle;\n thumbChar?: string;\n trackChar?: string;\n viewportHeight: number;\n}\n\nconst STYLE_CHARS: Record<string, { lowerThumb?: string; thumb: string; track: string; upperThumb?: string }> = {\n arrow: { lowerThumb: \"╽\", thumb: \"┃\", track: \"|\", upperThumb: \"╿\" },\n block: { thumb: \"█\", track: \"░\" },\n bold: { lowerThumb: \"╿\", thumb: \"│\", track: \"┃\", upperThumb: \"╽\" },\n classic: { thumb: \"┃\", track: \"|\" },\n dots: { thumb: \"●\", track: \"·\" },\n double: { thumb: \"┃\", track: \"║\" },\n doubleSingle: { lowerThumb: \"╽\", thumb: \"┃\", track: \"│\", upperThumb: \"╿\" },\n line: { thumb: \"│\", track: \" \" },\n round: { lowerThumb: \"╽\", thumb: \"┃\", track: \"│\", upperThumb: \"╿\" },\n single: { lowerThumb: \"╽\", thumb: \"┃\", track: \"│\", upperThumb: \"╿\" },\n singleDouble: { thumb: \"┃\", track: \"║\" },\n thick: { thumb: \"┃\", track: \"╏\" },\n};\n\nconst CORNER_CHARS: Record<string, { bottomLeft: string; bottomRight: string; topLeft: string; topRight: string }> = {\n arrow: { bottomLeft: \"└\", bottomRight: \"┘\", topLeft: \"┌\", topRight: \"┐\" },\n bold: { bottomLeft: \"┗\", bottomRight: \"┛\", topLeft: \"┏\", topRight: \"┓\" },\n classic: { bottomLeft: \"+\", bottomRight: \"+\", topLeft: \"+\", topRight: \"+\" },\n double: { bottomLeft: \"╚\", bottomRight: \"╝\", topLeft: \"╔\", topRight: \"╗\" },\n doubleSingle: { bottomLeft: \"╘\", bottomRight: \"╛\", topLeft: \"╒\", topRight: \"╕\" },\n round: { bottomLeft: \"╰\", bottomRight: \"╯\", topLeft: \"╭\", topRight: \"╮\" },\n single: { bottomLeft: \"└\", bottomRight: \"┘\", topLeft: \"┌\", topRight: \"┐\" },\n singleDouble: { bottomLeft: \"╙\", bottomRight: \"╜\", topLeft: \"╓\", topRight: \"╖\" },\n};\n\nconst getStyleChars = (style: ScrollBarStyle | undefined): { lowerThumb?: string; thumb: string; track: string; upperThumb?: string } => {\n const key = typeof style === \"string\" ? style : \"single\";\n\n return STYLE_CHARS[key] ?? STYLE_CHARS[\"single\"] ?? { thumb: \"┃\", track: \"│\" };\n};\n\nconst getCornerChars = (style: ScrollBarStyle | undefined): { bottomLeft: string; bottomRight: string; topLeft: string; topRight: string } => {\n const key = typeof style === \"string\" ? style : \"single\";\n\n return CORNER_CHARS[key] ?? CORNER_CHARS[\"single\"] ?? { bottomLeft: \"└\", bottomRight: \"┘\", topLeft: \"┌\", topRight: \"┐\" };\n};\n\nconst ScrollBar: React.FC<ScrollBarProps> = ({\n autoHide = false,\n color,\n contentHeight,\n dimColor,\n placement = \"right\",\n scrollOffset,\n style,\n thumbChar,\n trackChar,\n viewportHeight,\n}) => {\n const chars = useMemo(() => {\n const effectiveContentHeight = Math.max(contentHeight, viewportHeight);\n\n if (autoHide && effectiveContentHeight <= viewportHeight) {\n return null;\n }\n\n const styleChars = getStyleChars(style);\n const resolvedThumbChar = thumbChar ?? styleChars.thumb;\n const resolvedTrackChar = trackChar ?? styleChars.track;\n\n const totalHalfSteps = viewportHeight * 2;\n const viewportRatio = Math.min(viewportHeight / effectiveContentHeight, 1);\n const thumbLengthHalf = Math.max(2, Math.round(totalHalfSteps * viewportRatio));\n const maxScrollOffset = effectiveContentHeight - viewportHeight;\n const scrollProgress = maxScrollOffset > 0 ? Math.min(Math.max(scrollOffset / maxScrollOffset, 0), 1) : 0;\n const maxThumbStartHalf = totalHalfSteps - thumbLengthHalf;\n const thumbStartHalf = Math.round(scrollProgress * maxThumbStartHalf);\n const thumbEndHalf = thumbStartHalf + thumbLengthHalf;\n\n const result: string[] = [];\n\n for (let row = 0; row < viewportHeight; row += 1) {\n const upperHalf = row * 2;\n const lowerHalf = row * 2 + 1;\n const upperIsThumb = upperHalf >= thumbStartHalf && upperHalf < thumbEndHalf;\n const lowerIsThumb = lowerHalf >= thumbStartHalf && lowerHalf < thumbEndHalf;\n\n if (upperIsThumb && lowerIsThumb) {\n result.push(resolvedThumbChar);\n } else if (upperIsThumb && !lowerIsThumb) {\n result.push(styleChars.lowerThumb ?? resolvedThumbChar);\n } else if (!upperIsThumb && lowerIsThumb) {\n result.push(styleChars.upperThumb ?? resolvedThumbChar);\n } else {\n result.push(resolvedTrackChar);\n }\n }\n\n return result;\n }, [contentHeight, viewportHeight, scrollOffset, style, thumbChar, trackChar, autoHide]);\n\n if (chars === null) {\n return null;\n }\n\n if (placement === \"inset\") {\n return (\n <Box flexDirection=\"column\" flexShrink={0} width={1}>\n {chars.map((char, index) => (\n // eslint-disable-next-line react-x/no-array-index-key\n <Text color={color} dimColor={dimColor} key={index}>\n {char}\n </Text>\n ))}\n </Box>\n );\n }\n\n const cornerChars = getCornerChars(style);\n const topCorner = placement === \"left\" ? cornerChars.topLeft : cornerChars.topRight;\n const bottomCorner = placement === \"left\" ? cornerChars.bottomLeft : cornerChars.bottomRight;\n\n return (\n <Box flexDirection=\"column\" flexShrink={0} width={1}>\n <Text color={color} dimColor={dimColor}>\n {topCorner}\n </Text>\n {chars.map((char, index) => (\n // eslint-disable-next-line react-x/no-array-index-key\n <Text color={color} dimColor={dimColor} key={index}>\n {char}\n </Text>\n ))}\n <Text color={color} dimColor={dimColor}>\n {bottomCorner}\n </Text>\n </Box>\n );\n};\n\nexport type { ScrollBarPlacement, ScrollBarProps, ScrollBarStyle };\nexport { ScrollBar };\n",
|
|
35
|
+
"path": "registry/ui/scroll/scroll-bar.tsx",
|
|
36
|
+
"target": "components/ui/scroll/scroll-bar.tsx",
|
|
37
|
+
"type": "registry:ui"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"content": "/* eslint-disable react-you-might-not-need-an-effect/no-event-handler */\nimport useScrollInput from \"@visulima/tui/hooks/use-scroll-input\";\nimport type React from \"react\";\nimport type { Ref } from \"react\";\nimport { useCallback, useEffect, useImperativeHandle, useRef, useState } from \"react\";\n\nimport type { ControlledScrollViewRef } from \"./controlled-scroll-view\";\nimport { ControlledScrollView } from \"./controlled-scroll-view\";\nimport type { ScrollViewProps, ScrollViewRef } from \"./scroll-view\";\n\nexport type ScrollAlignment = \"auto\" | \"bottom\" | \"center\" | \"top\";\n\nexport interface ScrollListProps extends ScrollViewProps {\n readonly scrollAlignment?: ScrollAlignment;\n readonly selectedIndex?: number;\n}\n\nexport type ScrollListRef = ScrollViewRef;\n\nexport const ScrollList = (props: ScrollListProps & { ref?: Ref<ScrollListRef> }): React.JSX.Element => {\n const {\n children,\n keyboard = false,\n onContentHeightChange,\n onItemHeightChange,\n onScroll,\n onViewportSizeChange,\n overscan,\n ref,\n scrollAlignment = \"auto\",\n selectedIndex,\n vimBindings = false,\n virtualize,\n ...boxProps\n } = props;\n\n const scrollViewRef = useRef<ControlledScrollViewRef>(null);\n\n const [scrollOffset, setScrollOffset] = useState(0);\n const scrollOffsetRef = useRef(0);\n\n useEffect(() => {\n scrollOffsetRef.current = scrollOffset;\n }, [scrollOffset]);\n\n const updateScroll = useCallback(\n (newOffset: number) => {\n if (newOffset !== scrollOffsetRef.current) {\n setScrollOffset(newOffset);\n onScroll?.(newOffset);\n }\n },\n [onScroll],\n );\n\n const selectedIndexRef = useRef(selectedIndex);\n\n selectedIndexRef.current = selectedIndex;\n\n const getSelectionVisibleBounds = useCallback((): { max: number; min: number } | null => {\n const currentSelectedIndex = selectedIndexRef.current;\n\n if (currentSelectedIndex === undefined || currentSelectedIndex < 0) {\n return null;\n }\n\n const position = scrollViewRef.current?.getItemPosition(currentSelectedIndex);\n\n if (!position) {\n return null;\n }\n\n const viewportHeight = scrollViewRef.current?.getViewportHeight() ?? 0;\n const contentHeight = scrollViewRef.current?.getContentHeight() ?? 0;\n let minOffset = position.top + position.height - viewportHeight;\n let maxOffset = position.top;\n\n if (minOffset > maxOffset) {\n [minOffset, maxOffset] = [maxOffset, minOffset];\n }\n\n const globalMaxScroll = Math.max(0, contentHeight - viewportHeight);\n\n return {\n max: Math.min(globalMaxScroll, maxOffset),\n min: Math.max(0, minOffset),\n };\n }, []);\n\n const clampToSelectionBounds = useCallback(\n (targetOffset: number): number => {\n const contentHeight = scrollViewRef.current?.getContentHeight() ?? 0;\n const viewportHeight = scrollViewRef.current?.getViewportHeight() ?? 0;\n const globalMaxScroll = Math.max(0, contentHeight - viewportHeight);\n\n let clampedOffset = Math.max(0, Math.min(targetOffset, globalMaxScroll));\n\n const selectionBounds = getSelectionVisibleBounds();\n\n if (selectionBounds) {\n clampedOffset = Math.max(selectionBounds.min, Math.min(clampedOffset, selectionBounds.max));\n }\n\n return clampedOffset;\n },\n [getSelectionVisibleBounds],\n );\n\n const getConstrainedScrollOffset = useCallback((index: number, currentOffset: number, mode: ScrollAlignment, viewportHeightOverride?: number): number => {\n const position = scrollViewRef.current?.getItemPosition(index);\n\n if (!position) {\n return currentOffset;\n }\n\n const viewportHeight = viewportHeightOverride ?? scrollViewRef.current?.getViewportHeight() ?? 0;\n const contentHeight = scrollViewRef.current?.getContentHeight() ?? 0;\n let target = currentOffset;\n\n switch (mode) {\n case \"bottom\": {\n target = position.top + position.height - viewportHeight;\n\n break;\n }\n case \"center\": {\n target = position.top + position.height / 2 - viewportHeight / 2;\n\n break;\n }\n case \"top\": {\n target = position.top;\n\n break;\n }\n default: {\n const itemBottom = position.top + position.height;\n const isFillingViewport = position.top <= currentOffset && itemBottom >= currentOffset + viewportHeight;\n\n if (isFillingViewport) {\n target = currentOffset;\n } else if (position.top < currentOffset) {\n target = position.top;\n } else if (itemBottom > currentOffset + viewportHeight) {\n target = itemBottom - viewportHeight;\n }\n }\n }\n\n const maxScroll = Math.max(0, contentHeight - viewportHeight);\n\n return Math.max(0, Math.min(target, maxScroll));\n }, []);\n\n let renderScrollOffset = scrollOffset;\n\n if (selectedIndex !== undefined && selectedIndex >= 0) {\n renderScrollOffset = getConstrainedScrollOffset(selectedIndex, scrollOffset, scrollAlignment);\n }\n\n useEffect(() => {\n if (renderScrollOffset !== scrollOffset) {\n updateScroll(renderScrollOffset);\n }\n }, [renderScrollOffset, scrollOffset, updateScroll]);\n\n const handleViewportSizeChange = useCallback(\n (size: { height: number; width: number }, previousSize: { height: number; width: number }) => {\n if (selectedIndexRef.current !== undefined && selectedIndexRef.current >= 0) {\n const newOffset = getConstrainedScrollOffset(selectedIndexRef.current, scrollOffset, scrollAlignment, size.height);\n\n updateScroll(newOffset);\n }\n\n onViewportSizeChange?.(size, previousSize);\n },\n [onViewportSizeChange, getConstrainedScrollOffset, scrollOffset, scrollAlignment, updateScroll],\n );\n\n const handleItemHeightChange = useCallback(\n (index: number, height: number, previousHeight: number) => {\n const currentSelectedIndex = selectedIndexRef.current;\n\n if (currentSelectedIndex !== undefined && currentSelectedIndex >= 0) {\n if (index < currentSelectedIndex) {\n const newOffset = clampToSelectionBounds(scrollOffset + (height - previousHeight));\n\n updateScroll(newOffset);\n } else if (index === currentSelectedIndex) {\n const newOffset = getConstrainedScrollOffset(index, scrollOffset, scrollAlignment);\n\n updateScroll(newOffset);\n }\n }\n\n onItemHeightChange?.(index, height, previousHeight);\n },\n [onItemHeightChange, getConstrainedScrollOffset, scrollAlignment, scrollOffset, clampToSelectionBounds, updateScroll],\n );\n\n const handleContentHeightChange = useCallback(\n (height: number, previousHeight: number) => {\n if (selectedIndexRef.current !== undefined && selectedIndexRef.current >= 0) {\n const newOffset = getConstrainedScrollOffset(selectedIndexRef.current, scrollOffset, scrollAlignment);\n\n updateScroll(newOffset);\n }\n\n onContentHeightChange?.(height, previousHeight);\n },\n [onContentHeightChange, getConstrainedScrollOffset, scrollOffset, scrollAlignment, updateScroll],\n );\n\n const viewportHeightRef = useRef(0);\n\n const scrollByImperative = useCallback(\n (delta: number) => {\n const currentOffset = scrollOffsetRef.current;\n const clampedY = clampToSelectionBounds(currentOffset + delta);\n\n updateScroll(clampedY);\n },\n [clampToSelectionBounds, updateScroll],\n );\n\n const scrollToBottomImperative = useCallback(() => {\n const contentHeight = scrollViewRef.current?.getContentHeight() ?? 0;\n const viewportHeight = scrollViewRef.current?.getViewportHeight() ?? 0;\n const maxScroll = Math.max(0, contentHeight - viewportHeight);\n const clampedY = clampToSelectionBounds(maxScroll);\n\n updateScroll(clampedY);\n }, [clampToSelectionBounds, updateScroll]);\n\n const scrollToTopImperative = useCallback(() => {\n const clampedY = clampToSelectionBounds(0);\n\n updateScroll(clampedY);\n }, [clampToSelectionBounds, updateScroll]);\n\n const getViewportHeight = useCallback(() => viewportHeightRef.current, []);\n\n useScrollInput({\n getViewportHeight,\n isActive: keyboard,\n scrollBy: scrollByImperative,\n scrollToBottom: scrollToBottomImperative,\n scrollToTop: scrollToTopImperative,\n vimBindings,\n });\n\n useImperativeHandle(ref, () => {\n return {\n getBottomOffset: () => scrollViewRef.current?.getBottomOffset() ?? 0,\n getContentHeight: () => scrollViewRef.current?.getContentHeight() ?? 0,\n getItemHeight: (index: number) => scrollViewRef.current?.getItemHeight(index) ?? 0,\n getItemPosition: (index: number) => scrollViewRef.current?.getItemPosition(index) ?? null,\n getScrollOffset: () => scrollOffsetRef.current,\n getViewportHeight: () => scrollViewRef.current?.getViewportHeight() ?? 0,\n remeasure: () => scrollViewRef.current?.remeasure(),\n remeasureItem: (index: number) => scrollViewRef.current?.remeasureItem(index),\n scrollBy: scrollByImperative,\n scrollTo: (y: number) => {\n const clampedY = clampToSelectionBounds(y);\n\n updateScroll(clampedY);\n },\n scrollToBottom: scrollToBottomImperative,\n scrollToTop: scrollToTopImperative,\n };\n }, [clampToSelectionBounds, updateScroll, scrollByImperative, scrollToBottomImperative, scrollToTopImperative]);\n\n const handleViewportSizeChangeWithTracking = useCallback(\n (size: { height: number; width: number }, previousSize: { height: number; width: number }) => {\n viewportHeightRef.current = size.height;\n handleViewportSizeChange(size, previousSize);\n },\n [handleViewportSizeChange],\n );\n\n return (\n <ControlledScrollView\n onContentHeightChange={handleContentHeightChange}\n onItemHeightChange={handleItemHeightChange}\n onViewportSizeChange={handleViewportSizeChangeWithTracking}\n overscan={overscan}\n ref={scrollViewRef}\n scrollOffset={renderScrollOffset}\n virtualize={virtualize}\n {...boxProps}\n >\n {children}\n </ControlledScrollView>\n );\n};\n",
|
|
41
|
+
"path": "registry/ui/scroll/scroll-list.tsx",
|
|
42
|
+
"target": "components/ui/scroll/scroll-list.tsx",
|
|
43
|
+
"type": "registry:ui"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"content": "import type { Props as BoxProps } from \"@visulima/tui/components/box\";\nimport Box from \"@visulima/tui/components/box\";\nimport useScrollInput from \"@visulima/tui/hooks/use-scroll-input\";\nimport type React from \"react\";\nimport type { ReactNode, Ref } from \"react\";\nimport { useCallback, useImperativeHandle, useRef, useState } from \"react\";\n\nimport type { ControlledScrollViewRef } from \"./controlled-scroll-view\";\nimport { ControlledScrollView } from \"./controlled-scroll-view\";\nimport { ScrollBar } from \"./scroll-bar\";\nimport useStateRef from \"./use-state-ref\";\n\nexport interface ScrollViewProps extends BoxProps {\n children?: ReactNode;\n debug?: boolean;\n\n /**\n * Auto-scroll to bottom when content grows and user is at/near the bottom.\n * Pauses when user scrolls away from bottom; resumes when they return.\n * @default false\n */\n followOutput?: boolean;\n\n /**\n * Number of lines from bottom that still counts as \"at bottom\" for followOutput.\n * @default 3\n */\n followThreshold?: number;\n\n /**\n * Enable built-in keyboard navigation (arrows, page up/down, home/end, ctrl+u/d).\n * When enabled, the component becomes focusable.\n * @default false\n */\n keyboard?: boolean;\n onContentHeightChange?: (height: number, previousHeight: number) => void;\n onItemHeightChange?: (index: number, height: number, previousHeight: number) => void;\n\n /**\n * Fires when scrolled within `reachThreshold` lines of the end.\n * Only fires once per entry into the threshold zone.\n */\n onReachEnd?: () => void;\n\n /**\n * Fires when scrolled within `reachThreshold` lines of the start.\n * Only fires once per entry into the threshold zone.\n */\n onReachStart?: () => void;\n onScroll?: (scrollOffset: number) => void;\n onViewportSizeChange?: (size: { height: number; width: number }, previousSize: { height: number; width: number }) => void;\n\n /**\n * Number of extra items to render above/below the viewport when virtualized.\n * @default 3\n */\n overscan?: number;\n\n /**\n * Distance in lines from the edge to trigger onReachEnd/onReachStart.\n * @default 5\n */\n reachThreshold?: number;\n\n /**\n * Show a scrollbar track when content overflows the viewport.\n * @default false\n */\n scrollbar?: boolean;\n\n /**\n * Color of the scrollbar thumb and track.\n */\n scrollbarColor?: string;\n\n /**\n * Scrollbar visual style.\n * @default \"line\"\n */\n scrollbarStyle?: \"arrow\" | \"block\" | \"bold\" | \"classic\" | \"dots\" | \"double\" | \"doubleSingle\" | \"line\" | \"round\" | \"single\" | \"singleDouble\" | \"thick\";\n\n /**\n * Enable vim-style keybindings (j/k/g/G/u/d). Requires `keyboard` to be true.\n * @default false\n */\n vimBindings?: boolean;\n\n /**\n * Only render items visible in the viewport plus overscan. Requires items to be measured first.\n * @default false\n */\n virtualize?: boolean;\n}\n\nexport interface ScrollViewRef {\n getBottomOffset: () => number;\n getContentHeight: () => number;\n getItemHeight: (index: number) => number;\n getItemPosition: (index: number) => { height: number; top: number } | null;\n getScrollOffset: () => number;\n getViewportHeight: () => number;\n remeasure: () => void;\n remeasureItem: (index: number) => void;\n scrollBy: (delta: number) => void;\n scrollTo: (offset: number) => void;\n scrollToBottom: () => void;\n scrollToTop: () => void;\n}\n\nexport const ScrollView = ({\n children,\n debug = false,\n followOutput = false,\n followThreshold = 3,\n keyboard = false,\n onContentHeightChange,\n onItemHeightChange,\n onReachEnd,\n onReachStart,\n onScroll,\n onViewportSizeChange,\n overscan,\n reachThreshold = 5,\n ref,\n scrollbar = false,\n scrollbarColor,\n scrollbarStyle: scrollbarStyleProp = \"line\",\n vimBindings = false,\n virtualize,\n ...boxProps\n}: ScrollViewProps & { ref?: Ref<ScrollViewRef> }): React.JSX.Element => {\n const [scrollOffset, setScrollOffset, getScrollOffset] = useStateRef(0);\n const innerRef = useRef<ControlledScrollViewRef>(null);\n const contentHeightRef = useRef(0);\n const viewportHeightRef = useRef(0);\n const [scrollbarContentHeight, setScrollbarContentHeight] = useState(0);\n const [scrollbarViewportHeight, setScrollbarViewportHeight] = useState(0);\n\n // Reach callback debounce guards\n const reachEndFiredRef = useRef(false);\n const reachStartFiredRef = useRef(false);\n\n const getBottomOffset = useCallback(() => Math.max(0, contentHeightRef.current - viewportHeightRef.current), []);\n\n const checkReachBounds = useCallback(\n (offset: number) => {\n const bottomOffset = getBottomOffset();\n\n if (onReachEnd && offset >= bottomOffset - reachThreshold && !reachEndFiredRef.current) {\n reachEndFiredRef.current = true;\n onReachEnd();\n } else if (offset < bottomOffset - reachThreshold) {\n reachEndFiredRef.current = false;\n }\n\n if (onReachStart && offset <= reachThreshold && !reachStartFiredRef.current) {\n reachStartFiredRef.current = true;\n onReachStart();\n } else if (offset > reachThreshold) {\n reachStartFiredRef.current = false;\n }\n },\n [onReachEnd, onReachStart, reachThreshold, getBottomOffset],\n );\n\n const applyScroll = useCallback(\n (newOffset: number) => {\n if (newOffset !== getScrollOffset()) {\n setScrollOffset(newOffset);\n onScroll?.(newOffset);\n checkReachBounds(newOffset);\n }\n },\n [getScrollOffset, setScrollOffset, onScroll, checkReachBounds],\n );\n\n const handleContentHeightChange = useCallback(\n (height: number, previousHeight: number) => {\n const wasAtBottom = getScrollOffset() >= getBottomOffset() - followThreshold;\n\n contentHeightRef.current = height;\n setScrollbarContentHeight(height);\n onContentHeightChange?.(height, previousHeight);\n\n if (followOutput && wasAtBottom && height > previousHeight) {\n const newBottom = Math.max(0, height - viewportHeightRef.current);\n\n applyScroll(newBottom);\n } else {\n // Clamp scroll offset to the new maximum (content - viewport)\n const maxOffset = Math.max(0, height - viewportHeightRef.current);\n\n if (getScrollOffset() > maxOffset) {\n applyScroll(maxOffset);\n }\n }\n },\n [onContentHeightChange, followOutput, followThreshold, getScrollOffset, getBottomOffset, applyScroll],\n );\n\n const handleViewportSizeChange = useCallback(\n (size: { height: number; width: number }, previousSize: { height: number; width: number }) => {\n viewportHeightRef.current = size.height;\n setScrollbarViewportHeight(size.height);\n onViewportSizeChange?.(size, previousSize);\n },\n [onViewportSizeChange],\n );\n\n const scrollByImperative = useCallback(\n (delta: number) => {\n if (typeof delta !== \"number\" || Number.isNaN(delta)) {\n return;\n }\n\n // Clamp to [0, contentHeight - viewportHeight] so content can't scroll past the end\n const maxOffset = Math.max(0, contentHeightRef.current - viewportHeightRef.current);\n const newScrollTop = Math.max(0, Math.min(getScrollOffset() + delta, maxOffset));\n\n applyScroll(newScrollTop);\n },\n [getScrollOffset, applyScroll],\n );\n\n const scrollToImperative = useCallback(\n (offset: number) => {\n if (typeof offset !== \"number\" || Number.isNaN(offset)) {\n return;\n }\n\n // Clamp to [0, contentHeight - viewportHeight] so content can't scroll past the end\n const maxOffset = Math.max(0, contentHeightRef.current - viewportHeightRef.current);\n const newScrollTop = Math.max(0, Math.min(offset, maxOffset));\n\n applyScroll(newScrollTop);\n },\n [applyScroll],\n );\n\n const scrollToBottomImperative = useCallback(() => {\n const bottomOffset = getBottomOffset();\n\n applyScroll(bottomOffset);\n }, [getBottomOffset, applyScroll]);\n\n const scrollToTopImperative = useCallback(() => {\n applyScroll(0);\n }, [applyScroll]);\n\n const getViewportHeight = useCallback(() => viewportHeightRef.current, []);\n\n useScrollInput({\n getViewportHeight,\n isActive: keyboard,\n scrollBy: scrollByImperative,\n scrollToBottom: scrollToBottomImperative,\n scrollToTop: scrollToTopImperative,\n vimBindings,\n });\n\n useImperativeHandle(ref, () => {\n return {\n getBottomOffset,\n getContentHeight: () => contentHeightRef.current,\n getItemHeight: (index: number) => innerRef.current?.getItemHeight(index) ?? 0,\n getItemPosition: (index: number) => innerRef.current?.getItemPosition(index) ?? null,\n getScrollOffset,\n getViewportHeight: () => viewportHeightRef.current,\n remeasure: () => innerRef.current?.remeasure(),\n remeasureItem: (index: number) => innerRef.current?.remeasureItem(index),\n scrollBy: scrollByImperative,\n scrollTo: scrollToImperative,\n scrollToBottom: scrollToBottomImperative,\n scrollToTop: scrollToTopImperative,\n };\n }, [getBottomOffset, getScrollOffset, scrollByImperative, scrollToImperative, scrollToBottomImperative, scrollToTopImperative]);\n\n const showScrollbar = scrollbar && scrollbarContentHeight > scrollbarViewportHeight && scrollbarViewportHeight > 0;\n\n if (showScrollbar) {\n // Wrap in a row to place scrollbar beside content\n return (\n <Box flexDirection=\"row\" flexGrow={boxProps.flexGrow} flexShrink={boxProps.flexShrink} overflow=\"hidden\">\n <ControlledScrollView\n children={children}\n debug={debug}\n onContentHeightChange={handleContentHeightChange}\n onItemHeightChange={onItemHeightChange}\n onViewportSizeChange={handleViewportSizeChange}\n overscan={overscan}\n ref={innerRef}\n scrollOffset={scrollOffset}\n virtualize={virtualize}\n {...boxProps}\n flexGrow={1}\n />\n <ScrollBar\n color={scrollbarColor}\n contentHeight={scrollbarContentHeight}\n placement=\"inset\"\n scrollOffset={scrollOffset}\n style={scrollbarStyleProp}\n viewportHeight={scrollbarViewportHeight}\n />\n </Box>\n );\n }\n\n return (\n <ControlledScrollView\n children={children}\n debug={debug}\n onContentHeightChange={handleContentHeightChange}\n onItemHeightChange={onItemHeightChange}\n onViewportSizeChange={handleViewportSizeChange}\n overscan={overscan}\n ref={innerRef}\n scrollOffset={scrollOffset}\n virtualize={virtualize}\n {...boxProps}\n />\n );\n};\n",
|
|
47
|
+
"path": "registry/ui/scroll/scroll-view.tsx",
|
|
48
|
+
"target": "components/ui/scroll/scroll-view.tsx",
|
|
49
|
+
"type": "registry:ui"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"content": "import type React from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * Hook to manage state with immediate ref synchronization.\n * Useful for values that need to be read synchronously in imperative methods\n * but also trigger re-renders when changed.\n */\nconst useStateRef = <T>(initialValue: T): readonly [T, (update: React.SetStateAction<T>) => void, () => T] => {\n const [state, setStateInternal] = useState(initialValue);\n const ref = useRef(initialValue);\n\n const setState = useCallback((update: React.SetStateAction<T>) => {\n const nextValue = typeof update === \"function\" ? (update as (previous: T) => T)(ref.current) : update;\n\n ref.current = nextValue;\n setStateInternal(nextValue);\n }, []);\n\n const getState = useCallback((): T => ref.current, []);\n\n return [state, setState, getState] as const;\n};\n\nexport default useStateRef;\n",
|
|
53
|
+
"path": "registry/ui/scroll/use-state-ref.ts",
|
|
54
|
+
"target": "components/ui/scroll/use-state-ref.ts",
|
|
55
|
+
"type": "registry:ui"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "search-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Search Input",
|
|
6
|
+
"description": "Search Input component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"https://visulima.com/r/text-input.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport TextInput from \"@/components/ui/text-input\";\n\nexport type Props = {\n /**\n * Color of the accent elements (border, icon).\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Initial value.\n */\n readonly defaultValue?: string;\n\n /**\n * Node rendered before the input as a search affordance.\n * @default \"⌕\" (telephone recorder)\n */\n readonly icon?: ReactNode;\n\n /**\n * Disable the input.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fired whenever the value changes.\n */\n readonly onChange?: (value: string) => void;\n\n /**\n * Fired when the user presses Enter.\n */\n readonly onSubmit?: (value: string) => void;\n\n /**\n * Placeholder text shown when the input is empty.\n * @default \"Search...\"\n */\n readonly placeholder?: string;\n\n /**\n * Autocomplete suggestions.\n */\n readonly suggestions?: ReadonlyArray<string>;\n};\n\n/**\n * Search-styled text input with an icon and bordered container.\n * @returns A `ReactElement` wrapping `TextInput` with an icon column.\n */\nexport default function SearchInput({\n accentColor = \"blue\",\n defaultValue = \"\",\n icon = \"⌕\",\n isDisabled = false,\n onChange,\n onSubmit,\n placeholder = \"Search...\",\n suggestions,\n}: Props): ReactElement {\n return (\n <Box borderColor={accentColor} borderStyle=\"round\" gap={1} paddingX={1}>\n <Text color={accentColor}>{icon}</Text>\n <Box flexGrow={1}>\n <TextInput\n defaultValue={defaultValue}\n isDisabled={isDisabled}\n onChange={onChange}\n onSubmit={onSubmit}\n placeholder={placeholder}\n suggestions={suggestions}\n />\n </Box>\n </Box>\n );\n}\n\nexport { SearchInput };\nexport type { Props as SearchInputProps };\n",
|
|
21
|
+
"path": "registry/ui/search-input.tsx",
|
|
22
|
+
"target": "components/ui/search-input.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "select-input-indicator",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Select Input Indicator",
|
|
6
|
+
"description": "Select Input Indicator component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Default indicator component for SelectInput.\n *\n * Based on ink-select-input by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-select-input\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Color of the indicator when selected.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Whether the parent SelectInput has focus.\n * When `false`, the indicator is dimmed even if the item is selected.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Whether this indicator's item is currently selected.\n * @default false\n */\n readonly isSelected?: boolean;\n};\n\n/**\n * Renders a pointer indicator next to the selected item, or a blank space otherwise.\n * When unfocused, the indicator is rendered with `dimColor` to visually distinguish\n * the active select input from inactive ones.\n */\nexport default function SelectInputIndicator({ accentColor = \"blue\", isFocused = true, isSelected = false }: Props): ReactElement {\n return (\n <Box marginRight={1}>\n {/* eslint-disable-next-line @stylistic/multiline-ternary */}\n {isSelected ? (\n <Text color={accentColor} dimColor={!isFocused}>\n {\"\\u276F\"}\n </Text>\n ) : (\n <Text> </Text>\n )}\n </Box>\n );\n}\n\nexport { SelectInputIndicator };\nexport type { Props as SelectInputIndicatorProps };\n",
|
|
19
|
+
"path": "registry/ui/select-input-indicator.tsx",
|
|
20
|
+
"target": "components/ui/select-input-indicator.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "select-input-item",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Select Input Item",
|
|
6
|
+
"description": "Select Input Item component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Default item component for SelectInput.\n *\n * Based on ink-select-input by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-select-input\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Color of the label when selected.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Color of the label when not selected.\n */\n readonly defaultColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Whether the parent SelectInput has focus.\n * When `false`, the selected item is dimmed.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Whether this item is currently selected.\n * @default false\n */\n readonly isSelected?: boolean;\n\n /**\n * Text label to display for this item.\n */\n readonly label: string;\n};\n\n/**\n * Renders an item label, using `accentColor` when selected and `defaultColor` otherwise.\n * When unfocused, selected items are rendered with `dimColor`.\n */\nexport default function SelectInputItem({ accentColor = \"blue\", defaultColor, isFocused = true, isSelected = false, label }: Props): ReactElement {\n return (\n <Text color={isSelected ? accentColor : defaultColor} dimColor={isSelected && !isFocused}>\n {label}\n </Text>\n );\n}\n\nexport { SelectInputItem };\nexport type { Props as SelectInputItemProps };\n",
|
|
19
|
+
"path": "registry/ui/select-input-item.tsx",
|
|
20
|
+
"target": "components/ui/select-input-item.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "select-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Select Input",
|
|
6
|
+
"description": "Select Input component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"node:util",
|
|
14
|
+
"type-fest"
|
|
15
|
+
],
|
|
16
|
+
"registryDependencies": [
|
|
17
|
+
"https://visulima.com/r/select-input-indicator.json",
|
|
18
|
+
"https://visulima.com/r/select-input-item.json"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Select input component for Ink.\n *\n * Based on ink-select-input by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-select-input\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy of\n * this software and associated documentation files (the \"Software\"), to deal in the\n * Software without restriction, including without limitation the rights to use, copy,\n * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,\n * and to permit persons to whom the Software is furnished to do so, subject to the\n * following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,\n * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\n * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\n * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n */\nimport { isDeepStrictEqual } from \"node:util\";\n\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { FC, ReactElement } from \"react\";\nimport { createElement, useCallback, useEffect, useMemo, useReducer, useRef } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport type { Props as IndicatorProps } from \"@/components/ui/select-input-indicator\";\nimport SelectInputIndicator from \"@/components/ui/select-input-indicator\";\nimport type { Props as ItemProps } from \"@/components/ui/select-input-item\";\nimport SelectInputItem from \"@/components/ui/select-input-item\";\n\nexport type Item<V> = {\n /**\n * Called automatically when this item is selected (via Enter or number key).\n * Fires after `onSelect`. Useful for defining per-item behavior inline.\n */\n readonly action?: () => void;\n readonly key?: string;\n readonly label: string;\n readonly value: V;\n};\n\nexport type SeparatorItem = {\n /**\n * Marks this entry as a non-focusable separator line.\n */\n readonly isSeparator: true;\n readonly key?: string;\n\n /**\n * Custom separator text. Defaults to `\"───\"`.\n */\n readonly label?: string;\n};\n\nexport type SelectInputEntry<V> = Item<V> | SeparatorItem;\n\n/**\n * Type guard: returns `true` if the entry is a separator.\n */\nconst isSeparator = <V,>(entry: SelectInputEntry<V>): entry is SeparatorItem => \"isSeparator\" in entry && entry.isSeparator;\n\nexport type Props<V> = {\n /**\n * Color used for the selected item and indicator.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Color used for unselected items.\n */\n readonly defaultColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Controlled selected index. When provided, the component becomes controlled\n * and `initialIndex` is ignored. Update this value in the `onHighlight` callback\n * to track selection externally. If the index points to a separator, the nearest\n * selectable item is highlighted instead.\n */\n readonly index?: number;\n\n /**\n * Custom component to override the default indicator component.\n */\n readonly indicatorComponent?: FC<IndicatorProps>;\n\n /**\n * Index of initially-selected item in `items` array.\n * Ignored when `index` is provided.\n * When omitted (`undefined`), no item is highlighted until the user presses a key.\n * If the index points to a separator, the nearest selectable item is used.\n */\n readonly initialIndex?: number;\n\n /**\n * Whether the input is focused. Useful for when there are multiple\n * inputs on the screen and you need to control which one captures input.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Custom component to override the default item component.\n */\n readonly itemComponent?: FC<ItemProps>;\n\n // eslint-disable-next-line jsdoc/informative-docs -- \"items\" is the clearest name for this prop\n /** @default [] */\n readonly items?: ReadonlyArray<SelectInputEntry<V>>;\n\n /**\n * Number of visible items. Useful if the list is long and you want\n * to limit the display with a scrolling window.\n */\n readonly limit?: number;\n\n /**\n * Called when the user highlights a new item via arrow keys or `j`/`k`.\n * The second argument is the index of the highlighted item in the visible list.\n */\n readonly onHighlight?: (item: Item<V>, index: number) => void;\n\n /**\n * Called when the user selects an item via Enter or a number key (1-9).\n */\n readonly onSelect?: (item: Item<V>) => void;\n\n /**\n * Whether to reset the selection when items change.\n * Set to `false` to preserve the current selection across item updates.\n * @default true\n */\n readonly resetOnItemsChange?: boolean;\n};\n\nconst EMPTY_ITEMS: ReadonlyArray<never> = [];\nconst NUMBER_KEY_PATTERN = /^[1-9]$/;\nconst NO_SELECTION = -1;\nconst DEFAULT_SEPARATOR_LABEL = \"───\";\nconst SEPARATOR_SENTINEL: unique symbol = Symbol(\"separator\");\n\ntype SelectionState = {\n readonly rotateIndex: number;\n /** -1 means no item is highlighted. */\n readonly selectedIndex: number;\n};\n\n// eslint-disable-next-line @stylistic/operator-linebreak\ntype SelectionAction =\n | { readonly hasInitialSelection: boolean; readonly initialIndex: number; readonly type: \"reset\" }\n | { readonly rotateIndex: number; readonly selectedIndex: number; readonly type: \"navigate\" }\n | { readonly selectedIndex: number; readonly type: \"set\" };\n\nconst selectionReducer = (state: SelectionState, action: SelectionAction): SelectionState => {\n switch (action.type) {\n case \"navigate\": {\n return { rotateIndex: action.rotateIndex, selectedIndex: action.selectedIndex };\n }\n\n case \"reset\": {\n return {\n rotateIndex: 0,\n selectedIndex: action.hasInitialSelection ? action.initialIndex : NO_SELECTION,\n };\n }\n\n case \"set\": {\n return { rotateIndex: action.selectedIndex, selectedIndex: action.selectedIndex };\n }\n\n default: {\n return state;\n }\n }\n};\n\n/**\n * Rotate an array by a given offset, wrapping elements around.\n */\nconst rotateArray = <T,>(array: ReadonlyArray<T>, offset: number): T[] => {\n const { length } = array;\n\n if (length === 0) {\n return [];\n }\n\n // Normalize offset to a positive value within array bounds\n const normalizedOffset = ((offset % length) + length) % length;\n\n return [...array.slice(normalizedOffset), ...array.slice(0, normalizedOffset)];\n};\n\n/**\n * Return the windowed slice of items that should be rendered.\n */\nconst getVisibleItems = <V,>(\n items: ReadonlyArray<SelectInputEntry<V>>,\n hasLimit: boolean,\n currentRotateIndex: number,\n limit: number,\n): ReadonlyArray<SelectInputEntry<V>> => {\n if (hasLimit) {\n return rotateArray(items, currentRotateIndex).slice(0, limit);\n }\n\n return items;\n};\n\n/**\n * Find the next non-separator index in the given direction, wrapping around.\n * Returns NO_SELECTION if all entries are separators.\n */\nconst findNextSelectableIndex = <V,>(entries: ReadonlyArray<SelectInputEntry<V>>, fromIndex: number, direction: -1 | 1): number => {\n const { length } = entries;\n\n for (let step = 0; step < length; step += 1) {\n // Double-modulo idiom handles negative intermediate values correctly\n const candidate = (((fromIndex + (step + 1) * direction) % length) + length) % length;\n\n if (!isSeparator(entries[candidate] as SelectInputEntry<V>)) {\n return candidate;\n }\n }\n\n return NO_SELECTION;\n};\n\n/**\n * Resolve a requested index to the nearest selectable (non-separator) entry.\n * If the target itself is selectable, returns it unchanged.\n * If it's a separator, scans forward for the nearest selectable.\n * Returns NO_SELECTION if all entries are separators.\n */\nconst resolveSelectableIndex = <V,>(entries: ReadonlyArray<SelectInputEntry<V>>, targetIndex: number): number => {\n if (targetIndex < 0 || targetIndex >= entries.length) {\n return NO_SELECTION;\n }\n\n const entry = entries[targetIndex];\n\n if (entry && !isSeparator(entry)) {\n return targetIndex;\n }\n\n // Scan forward from the target to find the nearest selectable\n return findNextSelectableIndex(entries, targetIndex - 1, 1);\n};\n\n/**\n * A select input component for terminal UIs.\n *\n * Renders a list of items with an indicator showing the currently highlighted item.\n * Users can navigate with arrow keys or `j`/`k`, select with Enter, or jump to an\n * item by pressing a number key (1-9).\n */\nexport default function SelectInput<V>({\n accentColor = \"blue\",\n defaultColor,\n index,\n indicatorComponent = SelectInputIndicator,\n initialIndex,\n isFocused = true,\n itemComponent = SelectInputItem,\n items = EMPTY_ITEMS,\n limit: customLimit,\n onHighlight,\n onSelect,\n resetOnItemsChange = true,\n}: Props<V>): ReactElement {\n const { rows: terminalRows } = useWindowSize();\n\n // Auto-limit: when no explicit limit is set and items exceed terminal height,\n // cap to terminal rows so the list doesn't overflow the screen.\n const effectiveLimit = typeof customLimit === \"number\" ? customLimit : terminalRows;\n const hasLimit = items.length > effectiveLimit;\n const limit = hasLimit ? Math.min(effectiveLimit, items.length) : items.length;\n const lastIndex = limit - 1;\n\n const hasInitialSelection = typeof index === \"number\" || typeof initialIndex === \"number\";\n // eslint-disable-next-line @stylistic/no-extra-parens\n const rawInitial = typeof index === \"number\" ? index : (initialIndex ?? NO_SELECTION);\n // Clamp and skip separators for the initial selection\n const resolvedInitial = rawInitial === NO_SELECTION ? NO_SELECTION : resolveSelectableIndex(items, Math.min(rawInitial, lastIndex));\n\n const [state, dispatch] = useReducer(selectionReducer, {\n rotateIndex: resolvedInitial > lastIndex ? lastIndex - resolvedInitial : 0,\n selectedIndex: resolvedInitial,\n });\n\n const hasInitialSelectionRef = useRef(hasInitialSelection);\n const resolvedInitialRef = useRef(resolvedInitial);\n const stateRef = useRef(state);\n\n stateRef.current = state;\n const onSelectRef = useRef(onSelect);\n\n onSelectRef.current = onSelect;\n const previousItems = useRef(items);\n\n // Extract values for comparison, treating separators as a stable sentinel (Symbol avoids value collisions)\n const itemValues = useMemo(\n () =>\n // eslint-disable-next-line sonarjs/function-return-type -- legitimate union return: separator sentinel symbol vs entry value\n items.map((entry) => {\n if (isSeparator(entry)) {\n return SEPARATOR_SENTINEL;\n }\n\n return entry.value;\n }),\n [items],\n );\n\n const previousItemValues = useRef(itemValues);\n\n hasInitialSelectionRef.current = hasInitialSelection;\n resolvedInitialRef.current = resolvedInitial;\n\n useEffect(() => {\n if (resetOnItemsChange && !isDeepStrictEqual(previousItemValues.current, itemValues)) {\n dispatch({\n hasInitialSelection: hasInitialSelectionRef.current,\n initialIndex: resolvedInitialRef.current,\n type: \"reset\",\n });\n }\n\n previousItemValues.current = itemValues;\n previousItems.current = items;\n }, [items, itemValues, resetOnItemsChange]);\n\n useEffect(() => {\n if (typeof index === \"number\") {\n // Clamp and skip separators for controlled index\n const safeIndex = resolveSelectableIndex(items, Math.min(index, lastIndex));\n\n dispatch({ selectedIndex: safeIndex === NO_SELECTION ? 0 : safeIndex, type: \"set\" });\n }\n }, [index, items, lastIndex]);\n\n const handleUpArrow = useCallback(\n (currentRotateIndex: number, currentSelectedIndex: number) => {\n const visibleItems = getVisibleItems(items, hasLimit, currentRotateIndex, limit);\n\n // First keypress when nothing is selected: find the last selectable item\n if (currentSelectedIndex === NO_SELECTION) {\n const activateIndex = findNextSelectableIndex(visibleItems, 0, -1);\n\n if (activateIndex === NO_SELECTION) {\n return;\n }\n\n dispatch({ rotateIndex: currentRotateIndex, selectedIndex: activateIndex, type: \"navigate\" });\n\n if (typeof onHighlight === \"function\") {\n onHighlight(visibleItems[activateIndex] as Item<V>, activateIndex);\n }\n\n return;\n }\n\n // Navigate up: when at first position, wrap to bottom (incrementing rotateIndex\n // shifts the window forward, bringing earlier items into view at the bottom)\n const currentLastIndex = (hasLimit ? limit : items.length) - 1;\n const atFirstIndex = currentSelectedIndex === 0;\n const rawNextIndex = atFirstIndex ? currentLastIndex : currentSelectedIndex - 1;\n const nextRotateIndex = atFirstIndex && hasLimit ? currentRotateIndex + 1 : currentRotateIndex;\n\n const nextVisibleItems = getVisibleItems(items, hasLimit, nextRotateIndex, limit);\n const nextSelectedIndex = isSeparator(nextVisibleItems[rawNextIndex] as SelectInputEntry<V>)\n ? findNextSelectableIndex(nextVisibleItems, rawNextIndex, -1)\n : rawNextIndex;\n\n if (nextSelectedIndex === NO_SELECTION) {\n return;\n }\n\n dispatch({ rotateIndex: nextRotateIndex, selectedIndex: nextSelectedIndex, type: \"navigate\" });\n\n if (typeof onHighlight === \"function\") {\n onHighlight(nextVisibleItems[nextSelectedIndex] as Item<V>, nextSelectedIndex);\n }\n },\n [hasLimit, limit, items, onHighlight],\n );\n\n const handleDownArrow = useCallback(\n (currentRotateIndex: number, currentSelectedIndex: number) => {\n const visibleItems = getVisibleItems(items, hasLimit, currentRotateIndex, limit);\n\n // First keypress when nothing is selected: find the first selectable item\n if (currentSelectedIndex === NO_SELECTION) {\n const activateIndex = findNextSelectableIndex(visibleItems, visibleItems.length - 1, 1);\n\n if (activateIndex === NO_SELECTION) {\n return;\n }\n\n dispatch({ rotateIndex: currentRotateIndex, selectedIndex: activateIndex, type: \"navigate\" });\n\n if (typeof onHighlight === \"function\") {\n onHighlight(visibleItems[activateIndex] as Item<V>, activateIndex);\n }\n\n return;\n }\n\n // Navigate down: when at last position, wrap to top (decrementing rotateIndex\n // shifts the window backward, bringing later items into view at the top)\n const currentLastIndex = (hasLimit ? limit : items.length) - 1;\n const atLastIndex = currentSelectedIndex === currentLastIndex;\n const rawNextIndex = atLastIndex ? 0 : currentSelectedIndex + 1;\n const nextRotateIndex = atLastIndex && hasLimit ? currentRotateIndex - 1 : currentRotateIndex;\n\n const nextVisibleItems = getVisibleItems(items, hasLimit, nextRotateIndex, limit);\n const nextSelectedIndex = isSeparator(nextVisibleItems[rawNextIndex] as SelectInputEntry<V>)\n ? findNextSelectableIndex(nextVisibleItems, rawNextIndex, 1)\n : rawNextIndex;\n\n if (nextSelectedIndex === NO_SELECTION) {\n return;\n }\n\n dispatch({ rotateIndex: nextRotateIndex, selectedIndex: nextSelectedIndex, type: \"navigate\" });\n\n if (typeof onHighlight === \"function\") {\n onHighlight(nextVisibleItems[nextSelectedIndex] as Item<V>, nextSelectedIndex);\n }\n },\n [hasLimit, limit, items, onHighlight],\n );\n\n useInput(\n useCallback(\n (input: string, key) => {\n const { rotateIndex, selectedIndex } = stateRef.current;\n\n if (input === \"k\" || key.upArrow) {\n handleUpArrow(rotateIndex, selectedIndex);\n }\n\n if (input === \"j\" || key.downArrow) {\n handleDownArrow(rotateIndex, selectedIndex);\n }\n\n if (NUMBER_KEY_PATTERN.test(input)) {\n const targetIndex = Number.parseInt(input, 10) - 1;\n const visibleItems = getVisibleItems(items, hasLimit, rotateIndex, limit);\n\n if (targetIndex >= 0 && targetIndex < visibleItems.length) {\n const selectedItem = visibleItems[targetIndex];\n\n if (selectedItem && !isSeparator(selectedItem)) {\n onSelectRef.current?.(selectedItem);\n selectedItem.action?.();\n }\n }\n }\n\n if (key.return && selectedIndex !== NO_SELECTION) {\n const slicedItems = getVisibleItems(items, hasLimit, rotateIndex, limit);\n const selectedItem = slicedItems[selectedIndex];\n\n if (selectedItem && !isSeparator(selectedItem)) {\n onSelectRef.current?.(selectedItem);\n selectedItem.action?.();\n }\n }\n },\n [handleUpArrow, handleDownArrow, hasLimit, limit, items],\n ),\n { isActive: isFocused },\n );\n\n const slicedItems = getVisibleItems(items, hasLimit, state.rotateIndex, limit);\n\n return (\n <Box flexDirection=\"column\">\n {slicedItems.map((entry, itemIndex) => {\n if (isSeparator(entry)) {\n return (\n <Box key={entry.key ?? `separator-${String(itemIndex)}`}>\n <Text dimColor>{entry.label ?? DEFAULT_SEPARATOR_LABEL}</Text>\n </Box>\n );\n }\n\n const isSelected = itemIndex === state.selectedIndex;\n\n return (\n <Box key={entry.key ?? String(entry.value)}>\n {createElement(indicatorComponent, { accentColor, isFocused, isSelected })}\n {createElement(itemComponent, { ...entry, accentColor, defaultColor, isFocused, isSelected })}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { SelectInput };\nexport type { Props as SelectInputProps };\nexport type { Item as SelectInputItemType };\nexport type { SeparatorItem as SelectInputSeparator };\n",
|
|
23
|
+
"path": "registry/ui/select-input.tsx",
|
|
24
|
+
"target": "components/ui/select-input.tsx",
|
|
25
|
+
"type": "registry:ui"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "shimmer-text",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Shimmer Text",
|
|
6
|
+
"description": "Shimmer Text component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Text from \"@visulima/tui/components/text\";\nimport useAnimation from \"@visulima/tui/hooks/use-animation\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Width of the highlighted band in characters.\n * @default 3\n */\n readonly bandWidth?: number;\n\n /**\n * Base color of the non-highlighted text.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Highlight color of the sweeping band.\n * @default \"white\"\n */\n readonly highlightColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Milliseconds between frames of the shimmer.\n * @default 60\n */\n readonly interval?: number;\n\n /**\n * Text to render with a shimmer sweep.\n */\n readonly text: string;\n};\n\n/**\n * Text with an animated highlight band that sweeps across the characters.\n * Perfect for \"generating…\" states.\n * @param props Component props.\n * @param props.bandWidth Number of characters in the bright band.\n * @param props.color Color applied to characters outside the band.\n * @param props.highlightColor Color applied to characters inside the band.\n * @param props.interval Milliseconds between frames.\n * @param props.text Content to shimmer.\n * @returns A single `Text` element composed of per-character `Text` children.\n */\nexport default function ShimmerText({ bandWidth = 3, color, highlightColor = \"white\", interval = 60, text }: Props): ReactElement {\n const { frame } = useAnimation({ interval });\n\n // Split the raw text into codepoints once per `text` change. The\n // per-frame render reuses this array instead of re-splitting.\n // eslint-disable-next-line @typescript-eslint/no-misused-spread -- shimmer treats text as a sequence of glyphs; splitting by codepoint is intentional\n const characters = useMemo(() => [...text], [text]);\n const total = characters.length;\n const cycle = total + bandWidth;\n const position = total === 0 ? 0 : frame % cycle;\n\n // Static (non-highlighted) Text elements are memoized keyed by\n // text + color so only the handful of elements inside the sweep band\n // are recreated per frame.\n const baseElements = useMemo(\n () =>\n characters.map((char, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- shimmer characters are positionally stable\n <Text color={color} dimColor={color === undefined} key={index}>\n {char}\n </Text>\n )),\n [characters, color],\n );\n\n if (total === 0) {\n return <Text color={color}>{text}</Text>;\n }\n\n const parts: ReactElement[] = [];\n\n for (const [index, char] of characters.entries()) {\n const offset = position - index;\n const inBand = offset >= 0 && offset < bandWidth;\n\n if (!inBand) {\n parts.push(baseElements[index]!);\n\n continue;\n }\n\n parts.push(\n <Text bold color={highlightColor} key={`band-${index}`}>\n {char}\n </Text>,\n );\n }\n\n return <Text>{parts}</Text>;\n}\n\nexport { ShimmerText };\nexport type { Props as ShimmerTextProps };\n",
|
|
19
|
+
"path": "registry/ui/shimmer-text.tsx",
|
|
20
|
+
"target": "components/ui/shimmer-text.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "sidebar",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Sidebar",
|
|
6
|
+
"description": "Sidebar component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Main content, filling the remaining width.\n */\n readonly children: ReactNode;\n\n /**\n * Draw a border/rule between the sidebar and the content.\n * @default true\n */\n readonly divider?: boolean;\n\n /**\n * Color of the divider rule.\n * @default \"gray\"\n */\n readonly dividerColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Which side the sidebar sits on.\n * @default \"left\"\n */\n readonly side?: \"left\" | \"right\";\n\n /**\n * Sidebar content.\n */\n readonly sidebar: ReactNode;\n\n /**\n * Fixed sidebar width in cells.\n * @default 24\n */\n readonly width?: number;\n};\n\n/**\n * A two-pane layout: a fixed-width sidebar and a flexible content area, with an\n * optional vertical divider rule between them. The sidebar can sit on either\n * side.\n */\nexport default function Sidebar({ children, divider = true, dividerColor = \"gray\", side = \"left\", sidebar, width = 24 }: Props): ReactElement {\n // The divider is a single border edge on the side of the aside that faces\n // the content: right edge for a left sidebar, left edge for a right one.\n const aside = (\n <Box\n borderBottom={false}\n borderColor={dividerColor}\n borderLeft={divider && side === \"right\"}\n borderRight={divider && side === \"left\"}\n borderStyle=\"single\"\n borderTop={false}\n flexShrink={0}\n paddingX={1}\n width={width}\n >\n {sidebar}\n </Box>\n );\n\n const main = (\n <Box flexGrow={1} paddingX={1}>\n {children}\n </Box>\n );\n\n return (\n <Box flexDirection=\"row\">\n {side === \"left\" ? aside : main}\n {side === \"left\" ? main : aside}\n </Box>\n );\n}\n\nexport { Sidebar };\nexport type { Props as SidebarProps };\n",
|
|
19
|
+
"path": "registry/ui/sidebar.tsx",
|
|
20
|
+
"target": "components/ui/sidebar.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "slider",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Slider",
|
|
6
|
+
"description": "Slider component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable jsdoc/informative-docs, react/function-component-definition, sonarjs/no-nested-conditional */\n\n/**\n * Slider component for Ink.\n *\n * A keyboard-driven range input for terminal UIs, supporting\n * horizontal and vertical orientations.\n */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the filled portion of the track and thumb.\n * @default \"green\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Color for the empty portion of the track.\n * @default \"gray\"\n */\n readonly defaultColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Initial value when uncontrolled.\n * @default 0\n */\n readonly defaultValue?: number;\n\n /**\n * Character used for the empty portion of the track.\n * @default \"░\"\n */\n readonly emptyCharacter?: string;\n\n /**\n * Character used for the filled portion of the track.\n * @default \"█\"\n */\n readonly filledCharacter?: string;\n\n /**\n * When true, all input is ignored and the slider is dimmed.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * When true, the component responds to keyboard input.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Maximum value.\n * @default 100\n */\n readonly max?: number;\n\n /**\n * Minimum value.\n * @default 0\n */\n readonly min?: number;\n\n /**\n * Fires whenever the value changes.\n */\n readonly onChange?: (value: number) => void;\n\n /**\n * Layout direction of the slider.\n * @default \"horizontal\"\n */\n readonly orientation?: \"horizontal\" | \"vertical\";\n\n /**\n * Increment size for arrow key presses.\n * @default 1\n */\n readonly step?: number;\n\n /**\n * Character used to indicate the current position.\n * @default \"●\"\n */\n readonly thumbCharacter?: string;\n\n /**\n * Controlled value. When provided, `defaultValue` is ignored.\n */\n readonly value?: number;\n\n /**\n * Width of the slider track in columns (horizontal) or rows (vertical).\n * @default 20\n */\n readonly width?: number;\n};\n\nconst clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));\n\n/**\n * A keyboard-driven slider / range input.\n *\n * ```tsx\n * <Slider defaultValue={50} min={0} max={100} onChange={setValue} />\n * ```\n */\nexport default function Slider({\n accentColor = \"green\",\n defaultColor = \"gray\",\n defaultValue = 0,\n emptyCharacter = \"\\u2591\",\n filledCharacter = \"\\u2588\",\n isDisabled = false,\n isFocused = true,\n max = 100,\n min = 0,\n onChange,\n orientation = \"horizontal\",\n step = 1,\n thumbCharacter = \"\\u25CF\",\n value: controlledValue,\n width = 20,\n}: Props): ReactElement {\n // Ensure min <= max\n const safeMin = Math.min(min, max);\n const safeMax = Math.max(min, max);\n\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = useState(clamp(defaultValue, safeMin, safeMax));\n const current = isControlled ? clamp(controlledValue, safeMin, safeMax) : internalValue;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n const currentRef = useRef(current);\n\n currentRef.current = current;\n\n const setValue = useCallback(\n (next: number) => {\n const clamped = clamp(next, safeMin, safeMax);\n\n if (!isControlled) {\n setInternalValue(clamped);\n }\n\n onChangeRef.current?.(clamped);\n },\n [safeMin, safeMax, isControlled],\n );\n\n const largeStep = step * 10;\n const range = safeMax - safeMin;\n\n const inputHandler = useCallback(\n (\n input: string,\n key: {\n downArrow: boolean;\n end: boolean;\n home: boolean;\n leftArrow: boolean;\n pageDown: boolean;\n pageUp: boolean;\n rightArrow: boolean;\n upArrow: boolean;\n },\n ) => {\n const isForward = orientation === \"horizontal\" ? key.rightArrow : key.upArrow;\n const isBackward = orientation === \"horizontal\" ? key.leftArrow : key.downArrow;\n\n if (isForward) {\n setValue(currentRef.current + step);\n\n return;\n }\n\n if (isBackward) {\n setValue(currentRef.current - step);\n\n return;\n }\n\n if (key.pageUp) {\n setValue(currentRef.current + largeStep);\n\n return;\n }\n\n if (key.pageDown) {\n setValue(currentRef.current - largeStep);\n\n return;\n }\n\n if (key.home) {\n setValue(safeMin);\n\n return;\n }\n\n if (key.end) {\n setValue(safeMax);\n\n return;\n }\n\n // Number keys 0-9: jump to percentage\n if (input >= \"0\" && input <= \"9\") {\n const percent = Number.parseInt(input, 10) / 10;\n\n setValue(safeMin + range * percent);\n }\n },\n [step, largeStep, safeMin, safeMax, range, orientation, setValue],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n // Calculate thumb position\n const trackSize = Math.max(1, width);\n const ratio = range === 0 ? 0 : (current - safeMin) / range;\n const thumbPos = Math.round(ratio * (trackSize - 1));\n\n if (orientation === \"vertical\") {\n const rows: ReactElement[] = [];\n\n for (let index = trackSize - 1; index >= 0; index--) {\n const isThumb = index === thumbPos;\n const isFilled = index <= thumbPos;\n\n rows.push(\n <Box key={index}>\n <Text color={isDisabled ? undefined : isFilled ? accentColor : defaultColor} dimColor={isDisabled}>\n {isThumb ? thumbCharacter : isFilled ? filledCharacter : emptyCharacter}\n </Text>\n </Box>,\n );\n }\n\n return <Box flexDirection=\"column\">{rows}</Box>;\n }\n\n // Horizontal\n let track = \"\";\n\n for (let index = 0; index < trackSize; index++) {\n if (index === thumbPos) {\n track += thumbCharacter;\n } else if (index < thumbPos) {\n track += filledCharacter;\n } else {\n track += emptyCharacter;\n }\n }\n\n // Split into filled (before+including thumb) and empty (after thumb) for coloring\n const filledPart = track.slice(0, thumbPos);\n const thumb = track[thumbPos] ?? \"\";\n const emptyPart = track.slice(thumbPos + 1);\n\n return (\n <Box>\n <Text color={isDisabled ? undefined : accentColor} dimColor={isDisabled}>\n {filledPart}\n </Text>\n <Text bold color={isDisabled ? undefined : accentColor} dimColor={isDisabled}>\n {thumb}\n </Text>\n <Text color={isDisabled ? undefined : defaultColor} dimColor={isDisabled}>\n {emptyPart}\n </Text>\n </Box>\n );\n}\n\nexport { Slider };\nexport type { Props as SliderProps };\n",
|
|
19
|
+
"path": "registry/ui/slider.tsx",
|
|
20
|
+
"target": "components/ui/slider.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "sparkline",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Sparkline",
|
|
6
|
+
"description": "Sparkline component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Color applied to the chart glyphs.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Data points to plot. Values may be any real numbers.\n */\n readonly data: ReadonlyArray<number>;\n\n /**\n * Render with shade glyphs (`░▒▓█`) instead of vertical bars, for a\n * textured, monochrome-friendly look.\n * @default false\n */\n readonly dither?: boolean;\n\n /**\n * Optional fixed maximum for the vertical scale.\n * When omitted the maximum data value is used.\n */\n readonly max?: number;\n\n /**\n * Optional fixed minimum for the vertical scale.\n * When omitted the minimum data value is used.\n */\n readonly min?: number;\n};\n\nconst GLYPHS = \"▁▂▃▄▅▆▇█\";\n// No leading space: the lowest level stays visible so a flat series still\n// renders (a leading space would blank an all-equal dithered sparkline).\nconst DITHER_GLYPHS = \"░▒▓█\";\n\nconst resolveGlyph = (value: number, min: number, range: number, glyphs: string): string => {\n if (range === 0) {\n return glyphs[0] as string;\n }\n\n const ratio = (value - min) / range;\n const index = Math.max(0, Math.min(glyphs.length - 1, Math.floor(ratio * glyphs.length)));\n\n return glyphs[index] as string;\n};\n\ntype Extent = {\n readonly max: number;\n readonly min: number;\n};\n\n/**\n * Walk `data` once to find the min/max, falling back to user-supplied bounds.\n * Avoids `Math.min(...data)` which blows the call-stack on large arrays.\n */\nconst computeExtent = (data: ReadonlyArray<number>, minOverride: number | undefined, maxOverride: number | undefined): Extent => {\n let min = minOverride ?? Number.POSITIVE_INFINITY;\n let max = maxOverride ?? Number.NEGATIVE_INFINITY;\n\n if (minOverride === undefined || maxOverride === undefined) {\n for (const value of data) {\n if (minOverride === undefined && value < min) {\n min = value;\n }\n\n if (maxOverride === undefined && value > max) {\n max = value;\n }\n }\n }\n\n return { max, min };\n};\n\n/**\n * Inline bar chart using Unicode block glyphs.\n * @returns A `ReactElement` containing the glyph string, or `null` when\n * `data` is empty.\n */\nexport default function Sparkline({ color, data, dither = false, max, min }: Props): ReactElement | null {\n const chart = useMemo(() => {\n if (data.length === 0) {\n return \"\";\n }\n\n const { max: hi, min: lo } = computeExtent(data, min, max);\n const range = hi - lo;\n const glyphs = dither ? DITHER_GLYPHS : GLYPHS;\n\n return data.map((value) => resolveGlyph(value, lo, range, glyphs)).join(\"\");\n }, [data, dither, min, max]);\n\n if (chart.length === 0) {\n return null;\n }\n\n return <Text color={color}>{chart}</Text>;\n}\n\nexport { Sparkline };\nexport type { Props as SparklineProps };\n",
|
|
19
|
+
"path": "registry/ui/sparkline.tsx",
|
|
20
|
+
"target": "components/ui/sparkline.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|