@silvery/ui 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Kitty Graphics Protocol
3
+ *
4
+ * Encodes and manages images using the Kitty terminal graphics protocol.
5
+ * Images are transmitted as base64-encoded PNG data via APC (Application
6
+ * Program Command) escape sequences.
7
+ *
8
+ * Protocol reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
9
+ *
10
+ * Key concepts:
11
+ * - `a=T` — transmit and display the image
12
+ * - `f=100` — format is PNG (raw PNG data, terminal decodes it)
13
+ * - `m=0|1` — 0 = last/only chunk, 1 = more chunks follow
14
+ * - Chunks should be <= 4096 bytes of base64 to avoid overwhelming the terminal
15
+ * - Images can be assigned an `i=<id>` for later deletion
16
+ */
17
+
18
+ const APC_START = "\x1b_G"
19
+ const ST = "\x1b\\"
20
+
21
+ /** Maximum base64 bytes per chunk (Kitty recommendation) */
22
+ const MAX_CHUNK_SIZE = 4096
23
+
24
+ export interface KittyImageOptions {
25
+ /** Image width in terminal columns */
26
+ width?: number
27
+ /** Image height in terminal rows */
28
+ height?: number
29
+ /** Image ID for later reference/deletion (positive integer) */
30
+ id?: number
31
+ }
32
+
33
+ /**
34
+ * Encode a PNG image into Kitty graphics protocol escape sequences.
35
+ *
36
+ * The image data is base64-encoded and split into chunks of <= 4096 bytes.
37
+ * Each chunk is wrapped in an APC escape sequence. The first chunk carries
38
+ * the image metadata (action, format, dimensions, ID). Subsequent chunks
39
+ * only carry `m=1` or `m=0` to indicate continuation.
40
+ *
41
+ * @param pngData - Raw PNG image data
42
+ * @param opts - Optional dimensions and ID
43
+ * @returns A string containing the complete escape sequence(s)
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { readFileSync } from "fs"
48
+ * import { encodeKittyImage } from "@silvery/react"
49
+ *
50
+ * const png = readFileSync("photo.png")
51
+ * const seq = encodeKittyImage(png, { width: 40, height: 20 })
52
+ * process.stdout.write(seq)
53
+ * ```
54
+ */
55
+ export function encodeKittyImage(pngData: Buffer, opts?: KittyImageOptions): string {
56
+ const b64 = pngData.toString("base64")
57
+ const chunks = splitIntoChunks(b64, MAX_CHUNK_SIZE)
58
+
59
+ if (chunks.length === 0) {
60
+ // Empty image — send a single empty payload
61
+ return `${APC_START}${buildParams(opts, 0)};${ST}`
62
+ }
63
+
64
+ if (chunks.length === 1) {
65
+ // Single chunk — m=0 (last/only)
66
+ return `${APC_START}${buildParams(opts, 0)};${chunks[0]}${ST}`
67
+ }
68
+
69
+ // Multiple chunks
70
+ const parts: string[] = []
71
+
72
+ // First chunk carries full metadata, m=1 (more follows)
73
+ parts.push(`${APC_START}${buildParams(opts, 1)};${chunks[0]}${ST}`)
74
+
75
+ // Middle chunks — only m=1
76
+ for (let i = 1; i < chunks.length - 1; i++) {
77
+ parts.push(`${APC_START}m=1;${chunks[i]}${ST}`)
78
+ }
79
+
80
+ // Last chunk — m=0
81
+ parts.push(`${APC_START}m=0;${chunks[chunks.length - 1]}${ST}`)
82
+
83
+ return parts.join("")
84
+ }
85
+
86
+ /**
87
+ * Generate an escape sequence to delete a Kitty image by ID.
88
+ *
89
+ * Uses `a=d` (delete) with `d=i` (delete by image ID).
90
+ *
91
+ * @param id - The image ID to delete
92
+ * @returns The delete escape sequence
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * process.stdout.write(deleteKittyImage(42))
97
+ * ```
98
+ */
99
+ export function deleteKittyImage(id: number): string {
100
+ return `${APC_START}a=d,d=i,i=${id}${ST}`
101
+ }
102
+
103
+ /**
104
+ * Check if the current terminal likely supports the Kitty graphics protocol.
105
+ *
106
+ * This is a heuristic based on `TERM` and `TERM_PROGRAM` environment variables.
107
+ * For definitive detection, use a terminal query (send the graphics protocol
108
+ * query and check for a response), but that requires async I/O.
109
+ *
110
+ * Known supporting terminals: Kitty, WezTerm, Ghostty (partial), Konsole (partial).
111
+ *
112
+ * @returns `true` if the terminal likely supports Kitty graphics
113
+ */
114
+ export function isKittyGraphicsSupported(): boolean {
115
+ const term = process.env.TERM ?? ""
116
+ const termProgram = process.env.TERM_PROGRAM ?? ""
117
+
118
+ // Kitty terminal
119
+ if (term === "xterm-kitty" || termProgram === "kitty") return true
120
+
121
+ // WezTerm supports Kitty graphics protocol
122
+ if (termProgram === "WezTerm") return true
123
+
124
+ // Ghostty supports Kitty graphics
125
+ if (termProgram === "ghostty") return true
126
+
127
+ // Konsole 22.04+ supports Kitty graphics
128
+ if (termProgram === "konsole") return true
129
+
130
+ return false
131
+ }
132
+
133
+ // ============================================================================
134
+ // Internal helpers
135
+ // ============================================================================
136
+
137
+ /**
138
+ * Build the Kitty graphics protocol parameter string for the first chunk.
139
+ */
140
+ function buildParams(opts: KittyImageOptions | undefined, more: 0 | 1): string {
141
+ const parts = [`a=T`, `f=100`, `m=${more}`]
142
+
143
+ if (opts?.width != null) parts.push(`s=${opts.width}`)
144
+ if (opts?.height != null) parts.push(`v=${opts.height}`)
145
+ if (opts?.id != null) parts.push(`i=${opts.id}`)
146
+
147
+ return parts.join(",")
148
+ }
149
+
150
+ /**
151
+ * Split a string into chunks of at most `size` characters.
152
+ */
153
+ function splitIntoChunks(str: string, size: number): string[] {
154
+ if (str.length === 0) return []
155
+
156
+ const chunks: string[] = []
157
+ for (let i = 0; i < str.length; i += size) {
158
+ chunks.push(str.slice(i, i + size))
159
+ }
160
+ return chunks
161
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Sixel Encoder (Minimal Implementation)
3
+ *
4
+ * Sixel is an older image protocol supported by terminals like xterm, mlterm,
5
+ * foot, and some others. Images are encoded as DCS (Device Control String)
6
+ * sequences where each character encodes 6 vertical pixels.
7
+ *
8
+ * DCS format: `ESC P <params> q <sixel-data> ESC \`
9
+ *
10
+ * This is a minimal implementation that produces valid Sixel output for
11
+ * simple images. For production use with complex images, consider using
12
+ * a dedicated Sixel library that handles color quantization and dithering.
13
+ *
14
+ * Protocol reference: https://en.wikipedia.org/wiki/Sixel
15
+ *
16
+ * TODO: Full Sixel encoding with proper color quantization, dithering,
17
+ * and compression. The current implementation handles basic RGBA image data
18
+ * with a simple nearest-color palette approach.
19
+ */
20
+
21
+ const DCS_START = "\x1bP"
22
+ const ST = "\x1b\\"
23
+
24
+ /** Sixel introduces a color with `#<index>;2;<r>;<g>;<b>` (RGB percentages 0-100) */
25
+ const SIXEL_NEWLINE = "-"
26
+
27
+ export interface SixelImageData {
28
+ /** Image width in pixels */
29
+ width: number
30
+ /** Image height in pixels */
31
+ height: number
32
+ /** RGBA pixel data (4 bytes per pixel: R, G, B, A), row-major order */
33
+ data: Uint8Array
34
+ }
35
+
36
+ /**
37
+ * Encode RGBA image data as a Sixel escape sequence.
38
+ *
39
+ * This is a basic implementation that:
40
+ * 1. Quantizes colors to a small palette (up to 256 colors)
41
+ * 2. Encodes 6-row bands as Sixel characters
42
+ * 3. Wraps in a DCS escape sequence
43
+ *
44
+ * For transparent pixels (alpha < 128), the background shows through.
45
+ *
46
+ * @param imageData - Image dimensions and RGBA pixel data
47
+ * @returns A DCS escape sequence containing the Sixel-encoded image
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const img = { width: 10, height: 12, data: new Uint8Array(10 * 12 * 4) }
52
+ * const seq = encodeSixel(img)
53
+ * process.stdout.write(seq)
54
+ * ```
55
+ */
56
+ export function encodeSixel(imageData: SixelImageData): string {
57
+ const { width, height, data } = imageData
58
+
59
+ if (width === 0 || height === 0 || data.length === 0) {
60
+ return `${DCS_START}q${ST}`
61
+ }
62
+
63
+ // Build a simple palette by collecting unique (quantized) colors
64
+ const palette = new Map<string, number>()
65
+ const pixelColors = new Uint16Array(width * height) // palette index per pixel (0 = transparent)
66
+ let nextColorIndex = 1 // 0 reserved for transparent/background
67
+
68
+ for (let y = 0; y < height; y++) {
69
+ for (let x = 0; x < width; x++) {
70
+ const offset = (y * width + x) * 4
71
+ const r = data[offset]!
72
+ const g = data[offset + 1]!
73
+ const b = data[offset + 2]!
74
+ const a = data[offset + 3]!
75
+
76
+ if (a < 128) {
77
+ // Transparent — leave as 0
78
+ continue
79
+ }
80
+
81
+ // Quantize to 6-bit per channel (64 levels) to keep palette small
82
+ const qr = (r >> 2) & 0x3f
83
+ const qg = (g >> 2) & 0x3f
84
+ const qb = (b >> 2) & 0x3f
85
+ const key = `${qr},${qg},${qb}`
86
+
87
+ let idx = palette.get(key)
88
+ if (idx == null) {
89
+ if (nextColorIndex >= 256) {
90
+ // Palette full — find closest existing color (simple fallback)
91
+ idx = 1
92
+ } else {
93
+ idx = nextColorIndex++
94
+ palette.set(key, idx)
95
+ }
96
+ }
97
+
98
+ pixelColors[y * width + x] = idx
99
+ }
100
+ }
101
+
102
+ // Build Sixel data
103
+ const parts: string[] = []
104
+
105
+ // Raster attributes: Pan;Pad;Ph;Pv (aspect ratio 1:1, width, height)
106
+ parts.push(`"1;1;${width};${height}`)
107
+
108
+ // Define palette colors
109
+ for (const [key, idx] of palette) {
110
+ const [qr, qg, qb] = key.split(",").map(Number)
111
+ // Convert from 6-bit (0-63) to percentage (0-100)
112
+ const rPct = Math.round((qr! / 63) * 100)
113
+ const gPct = Math.round((qg! / 63) * 100)
114
+ const bPct = Math.round((qb! / 63) * 100)
115
+ parts.push(`#${idx};2;${rPct};${gPct};${bPct}`)
116
+ }
117
+
118
+ // Encode pixel data in 6-row bands
119
+ for (let bandY = 0; bandY < height; bandY += 6) {
120
+ if (bandY > 0) {
121
+ parts.push(SIXEL_NEWLINE) // Move to next sixel row
122
+ }
123
+
124
+ // For each color in the palette, emit the sixel row
125
+ // (Only emit colors that appear in this band)
126
+ const bandColors = new Set<number>()
127
+ for (let dy = 0; dy < 6 && bandY + dy < height; dy++) {
128
+ for (let x = 0; x < width; x++) {
129
+ const ci = pixelColors[(bandY + dy) * width + x]!
130
+ if (ci > 0) bandColors.add(ci)
131
+ }
132
+ }
133
+
134
+ let first = true
135
+ for (const colorIdx of bandColors) {
136
+ if (!first) {
137
+ parts.push("$") // Carriage return within sixel line (reposition to start)
138
+ }
139
+ first = false
140
+
141
+ parts.push(`#${colorIdx}`)
142
+
143
+ // Build the sixel characters for this color in this band
144
+ for (let x = 0; x < width; x++) {
145
+ let sixelBits = 0
146
+ for (let dy = 0; dy < 6; dy++) {
147
+ const y = bandY + dy
148
+ if (y < height && pixelColors[y * width + x] === colorIdx) {
149
+ sixelBits |= 1 << dy
150
+ }
151
+ }
152
+ // Sixel character = bits + 63 (0x3F)
153
+ parts.push(String.fromCharCode(sixelBits + 63))
154
+ }
155
+ }
156
+ }
157
+
158
+ return `${DCS_START}q${parts.join("")}${ST}`
159
+ }
160
+
161
+ /**
162
+ * Check if the current terminal likely supports the Sixel protocol.
163
+ *
164
+ * This is a heuristic based on environment variables. For definitive
165
+ * detection, send a DA1 (Device Attributes) query and check for "4"
166
+ * in the response, but that requires async I/O.
167
+ *
168
+ * Known supporting terminals: xterm (with +sixel), mlterm, foot, mintty,
169
+ * WezTerm, Contour, Sixel-enabled builds of various terminals.
170
+ *
171
+ * @returns `true` if the terminal likely supports Sixel
172
+ */
173
+ export function isSixelSupported(): boolean {
174
+ const term = process.env.TERM ?? ""
175
+ const termProgram = process.env.TERM_PROGRAM ?? ""
176
+
177
+ // mlterm supports Sixel natively
178
+ if (termProgram === "mlterm" || term.startsWith("mlterm")) return true
179
+
180
+ // foot supports Sixel
181
+ if (termProgram === "foot" || term === "foot" || term === "foot-extra") return true
182
+
183
+ // WezTerm supports Sixel
184
+ if (termProgram === "WezTerm") return true
185
+
186
+ // mintty supports Sixel
187
+ if (termProgram === "mintty") return true
188
+
189
+ // xterm might support Sixel if compiled with +sixel
190
+ // We can't know for sure from env alone, so we don't claim support
191
+ // (the user can set protocol='sixel' explicitly)
192
+
193
+ return false
194
+ }
package/src/images.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * silvery/images -- Image rendering via Kitty graphics and Sixel protocol.
3
+ *
4
+ * ```tsx
5
+ * import { Image } from '@silvery/ui/images'
6
+ *
7
+ * <Image src={pngBuffer} width={40} height={15} fallback="[image]" />
8
+ * ```
9
+ *
10
+ * Auto-detects the best available protocol (Kitty > Sixel > text fallback).
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ export { Image } from "./image/Image"
16
+ export type { ImageProps } from "./image/Image"
17
+
18
+ export { encodeKittyImage, deleteKittyImage, isKittyGraphicsSupported } from "./image/kitty-graphics"
19
+ export type { KittyImageOptions } from "./image/kitty-graphics"
20
+
21
+ export { encodeSixel, isSixelSupported } from "./image/sixel-encoder"
22
+ export type { SixelImageData } from "./image/sixel-encoder"
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * silvery-ui - UI components for Ink/silvery TUI apps
3
+ *
4
+ * Progress indicators, spinners, and task wrappers for CLI applications.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // Fluent task API (recommended)
9
+ * import { task, tasks } from "@silvery/ui/progress";
10
+ *
11
+ * const data = await task("Loading").wrap(fetchData());
12
+ *
13
+ * const results = await tasks()
14
+ * .add("Loading", () => fetchData())
15
+ * .add("Processing", () => process())
16
+ * .run({ clear: true });
17
+ *
18
+ * // Low-level CLI components
19
+ * import { Spinner, ProgressBar } from "@silvery/ui/cli";
20
+ *
21
+ * // React/TUI components
22
+ * import { Spinner, ProgressBar } from "@silvery/ui/react";
23
+ * ```
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+
28
+ // Re-export everything for convenience
29
+ export * from "./types.js"
30
+ export * from "./cli/index.js"
31
+ export * from "./wrappers/index.js"
32
+
33
+ // Note: React components should be imported from "@silvery/ui/react"
34
+ // to avoid requiring React as a dependency for CLI-only usage
@@ -0,0 +1,155 @@
1
+ /**
2
+ * React Select component for silvery/Ink TUI apps
3
+ *
4
+ * Single-choice selection list with keyboard navigation.
5
+ */
6
+
7
+ import React, { useState, useEffect, useCallback } from "react"
8
+ import type { SelectProps, SelectOption } from "../types.js"
9
+
10
+ /**
11
+ * Scrollable single-choice selection list
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * import { Select } from "@silvery/ui/input";
16
+ *
17
+ * function SettingsView() {
18
+ * const [theme, setTheme] = useState("light");
19
+ *
20
+ * return (
21
+ * <Select
22
+ * options={[
23
+ * { label: "Light", value: "light" },
24
+ * { label: "Dark", value: "dark" },
25
+ * { label: "System", value: "system" },
26
+ * ]}
27
+ * value={theme}
28
+ * onChange={setTheme}
29
+ * />
30
+ * );
31
+ * }
32
+ * ```
33
+ */
34
+ export function Select<T>({
35
+ options,
36
+ value,
37
+ onChange,
38
+ maxVisible = 10,
39
+ highlightIndex: controlledHighlightIndex,
40
+ onHighlightChange,
41
+ }: SelectProps<T>): React.ReactElement {
42
+ // Find the index of the currently selected value
43
+ const selectedIndex = options.findIndex((opt) => opt.value === value)
44
+
45
+ // Internal highlight state (for uncontrolled mode)
46
+ const [internalHighlightIndex, setInternalHighlightIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0)
47
+
48
+ // Use controlled or internal highlight index
49
+ const highlightIndex = controlledHighlightIndex ?? internalHighlightIndex
50
+
51
+ // Calculate scroll window
52
+ const scrollOffset = Math.max(0, Math.min(highlightIndex - Math.floor(maxVisible / 2), options.length - maxVisible))
53
+ const visibleOptions = options.slice(scrollOffset, scrollOffset + maxVisible)
54
+ const hasMoreAbove = scrollOffset > 0
55
+ const hasMoreBelow = scrollOffset + maxVisible < options.length
56
+
57
+ // Sync internal highlight when value changes externally
58
+ useEffect(() => {
59
+ if (controlledHighlightIndex === undefined && selectedIndex >= 0) {
60
+ setInternalHighlightIndex(selectedIndex)
61
+ }
62
+ }, [selectedIndex, controlledHighlightIndex])
63
+
64
+ return (
65
+ <div data-silvery-select>
66
+ {hasMoreAbove && <div data-silvery-select-scroll-indicator="up">...</div>}
67
+ {visibleOptions.map((option, visibleIdx) => {
68
+ const actualIndex = scrollOffset + visibleIdx
69
+ const isSelected = option.value === value
70
+ const isHighlighted = actualIndex === highlightIndex
71
+
72
+ return (
73
+ <div key={actualIndex} data-silvery-select-option data-selected={isSelected} data-highlighted={isHighlighted}>
74
+ <span data-silvery-select-indicator>{isSelected ? ">" : " "}</span>
75
+ <span data-silvery-select-label>{option.label}</span>
76
+ </div>
77
+ )
78
+ })}
79
+ {hasMoreBelow && <div data-silvery-select-scroll-indicator="down">...</div>}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Hook for managing select state with keyboard navigation
86
+ *
87
+ * @example
88
+ * ```tsx
89
+ * function MySelect() {
90
+ * const options = [
91
+ * { label: "Option A", value: "a" },
92
+ * { label: "Option B", value: "b" },
93
+ * ];
94
+ *
95
+ * const { highlightIndex, moveUp, moveDown, select, value } = useSelect({
96
+ * options,
97
+ * initialValue: "a",
98
+ * });
99
+ *
100
+ * useInput((input, key) => {
101
+ * if (key.upArrow) moveUp();
102
+ * if (key.downArrow) moveDown();
103
+ * if (key.return) select();
104
+ * });
105
+ *
106
+ * return <Select options={options} value={value} highlightIndex={highlightIndex} />;
107
+ * }
108
+ * ```
109
+ */
110
+ export function useSelect<T>({
111
+ options,
112
+ initialValue,
113
+ onChange,
114
+ }: {
115
+ options: SelectOption<T>[]
116
+ initialValue?: T
117
+ onChange?: (value: T) => void
118
+ }): {
119
+ value: T | undefined
120
+ highlightIndex: number
121
+ moveUp: () => void
122
+ moveDown: () => void
123
+ select: () => void
124
+ setHighlightIndex: (index: number) => void
125
+ } {
126
+ const initialIndex = initialValue !== undefined ? options.findIndex((opt) => opt.value === initialValue) : 0
127
+
128
+ const [highlightIndex, setHighlightIndex] = useState(Math.max(0, initialIndex))
129
+ const [value, setValue] = useState<T | undefined>(initialValue)
130
+
131
+ const moveUp = useCallback(() => {
132
+ setHighlightIndex((i) => Math.max(0, i - 1))
133
+ }, [])
134
+
135
+ const moveDown = useCallback(() => {
136
+ setHighlightIndex((i) => Math.min(options.length - 1, i + 1))
137
+ }, [options.length])
138
+
139
+ const select = useCallback(() => {
140
+ const option = options[highlightIndex]
141
+ if (option) {
142
+ setValue(option.value)
143
+ onChange?.(option.value)
144
+ }
145
+ }, [highlightIndex, options, onChange])
146
+
147
+ return {
148
+ value,
149
+ highlightIndex,
150
+ moveUp,
151
+ moveDown,
152
+ select,
153
+ setHighlightIndex,
154
+ }
155
+ }