@effect-tui/react 0.9.1 → 0.9.2
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/dist/src/dev/Toast.d.ts.map +1 -1
- package/dist/src/dev/Toast.js +29 -35
- package/dist/src/dev/Toast.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +11 -6
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +6 -4
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/motion/index.d.ts +1 -0
- package/dist/src/motion/index.d.ts.map +1 -1
- package/dist/src/motion/index.js +1 -0
- package/dist/src/motion/index.js.map +1 -1
- package/dist/src/motion/use-sequence.d.ts +27 -0
- package/dist/src/motion/use-sequence.d.ts.map +1 -0
- package/dist/src/motion/use-sequence.js +35 -0
- package/dist/src/motion/use-sequence.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/dev/Toast.tsx +29 -43
- package/src/hooks/use-scroll.ts +11 -6
- package/src/hosts/box.ts +6 -4
- package/src/motion/index.ts +1 -0
- package/src/motion/use-sequence.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "React bindings for @effect-tui/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@effect-tui/core": "^0.9.
|
|
86
|
+
"@effect-tui/core": "^0.9.2",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
package/src/dev/Toast.tsx
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { Colors } from "@effect-tui/core"
|
|
5
5
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
|
|
6
|
+
import { useColorSpring } from "../motion/hooks.js"
|
|
7
|
+
import { useSequence } from "../motion/use-sequence.js"
|
|
6
8
|
|
|
7
9
|
// ─────────────────────────────────────────────────────────────
|
|
8
10
|
// Types
|
|
@@ -82,56 +84,40 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
82
84
|
// Screenshot Toast Animation
|
|
83
85
|
// ─────────────────────────────────────────────────────────────
|
|
84
86
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
+
// Bright flash color for initial state
|
|
88
|
+
const FLASH_COLOR = { r: 255, g: 255, b: 220, a: 1 }
|
|
89
|
+
// Final green success color
|
|
90
|
+
const SUCCESS_COLOR = { r: 30, g: 70, b: 40, a: 1 }
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
fg: Colors.rgb(180, 200, 220),
|
|
96
|
-
},
|
|
97
|
-
flash: {
|
|
98
|
-
icon: "⚡",
|
|
99
|
-
bg: Colors.rgb(255, 255, 200), // Bright flash!
|
|
100
|
-
fg: Colors.rgb(60, 60, 40),
|
|
101
|
-
},
|
|
102
|
-
success: {
|
|
103
|
-
icon: "✓",
|
|
104
|
-
bg: Colors.rgb(30, 70, 40),
|
|
105
|
-
fg: Colors.rgb(140, 230, 140),
|
|
106
|
-
},
|
|
107
|
-
}
|
|
92
|
+
function ScreenshotToast({ message }: { message: string }) {
|
|
93
|
+
// Emoji sequence: 📷 → 📸 → 📷 (camera → flash → camera)
|
|
94
|
+
const emoji = useSequence({
|
|
95
|
+
keyframes: ["📷", "📸", "📷"],
|
|
96
|
+
times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
|
|
97
|
+
duration: 2500,
|
|
98
|
+
})
|
|
108
99
|
|
|
109
|
-
|
|
110
|
-
const [
|
|
100
|
+
// Background: bright flash → fade to green
|
|
101
|
+
const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
|
|
111
102
|
|
|
103
|
+
// Trigger fade after flash phase
|
|
112
104
|
useEffect(() => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const flashTimer = setTimeout(() => setPhase("flash"), cameraDelay)
|
|
119
|
-
const successTimer = setTimeout(() => setPhase("success"), flashDelay)
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
setBg(SUCCESS_COLOR)
|
|
107
|
+
}, 200) // Start fade at 200ms
|
|
108
|
+
return () => clearTimeout(timer)
|
|
109
|
+
}, [setBg])
|
|
120
110
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
clearTimeout(successTimer)
|
|
124
|
-
}
|
|
125
|
-
}, [createdAt])
|
|
126
|
-
|
|
127
|
-
const style = SCREENSHOT_PHASES[phase]
|
|
128
|
-
const content = ` ${style.icon} ${message} `
|
|
111
|
+
const bgValue = bg.get()
|
|
112
|
+
const isFlash = emoji === "📸"
|
|
129
113
|
|
|
130
114
|
return (
|
|
131
115
|
<hstack>
|
|
132
116
|
<spacer />
|
|
133
|
-
<box bg={
|
|
134
|
-
<text fg={
|
|
117
|
+
<box bg={Colors.rgb(bgValue.r, bgValue.g, bgValue.b)} padding={{ x: 1 }}>
|
|
118
|
+
<text fg={isFlash ? Colors.rgb(40, 40, 20) : Colors.rgb(140, 230, 140)}>
|
|
119
|
+
{` ${emoji} ${message} `}
|
|
120
|
+
</text>
|
|
135
121
|
</box>
|
|
136
122
|
</hstack>
|
|
137
123
|
)
|
|
@@ -151,7 +137,7 @@ export function ToastContainer() {
|
|
|
151
137
|
|
|
152
138
|
// Special animated toast for screenshots
|
|
153
139
|
if (toast.type === "screenshot") {
|
|
154
|
-
return <ScreenshotToast message={toast.message}
|
|
140
|
+
return <ScreenshotToast message={toast.message} />
|
|
155
141
|
}
|
|
156
142
|
|
|
157
143
|
const style = TOAST_STYLES[toast.type]
|
|
@@ -179,7 +165,7 @@ export function useScreenshotToast() {
|
|
|
179
165
|
|
|
180
166
|
return useCallback(
|
|
181
167
|
(path: string) => {
|
|
182
|
-
show(`Screenshot
|
|
168
|
+
show(`Screenshot copied!`, "screenshot", 2500)
|
|
183
169
|
},
|
|
184
170
|
[show],
|
|
185
171
|
)
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -209,9 +209,11 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
209
209
|
// Fractional accumulator for smooth sub-pixel scrolling
|
|
210
210
|
const accumulatorRef = useRef(0)
|
|
211
211
|
|
|
212
|
-
//
|
|
212
|
+
// Refs for scrollToVisible so it doesn't change on every scroll
|
|
213
213
|
const offsetRef = useRef(offset)
|
|
214
214
|
offsetRef.current = offset
|
|
215
|
+
// viewportSizeRef is ONLY updated by handleViewportSize to avoid stale state overwriting
|
|
216
|
+
const viewportSizeRef = useRef(viewportSize)
|
|
215
217
|
|
|
216
218
|
// Calculate derived state
|
|
217
219
|
const maxOffset = Math.max(0, contentSize - viewportSize)
|
|
@@ -287,6 +289,8 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
287
289
|
const handleViewportSize = useCallback(
|
|
288
290
|
(width: number, height: number) => {
|
|
289
291
|
const newSize = axis === "vertical" ? height : width
|
|
292
|
+
// Sync ref immediately so scrollToVisible uses correct size
|
|
293
|
+
viewportSizeRef.current = newSize
|
|
290
294
|
setViewportSize(newSize)
|
|
291
295
|
setViewportMeasured(true)
|
|
292
296
|
},
|
|
@@ -349,10 +353,11 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
349
353
|
useKeyboard(handleKey)
|
|
350
354
|
|
|
351
355
|
// Scroll to make a position visible (for keeping selection in view)
|
|
352
|
-
// Uses refs to avoid
|
|
356
|
+
// Uses refs to avoid stale closures - only triggers when selection changes
|
|
353
357
|
const scrollToVisible = useCallback(
|
|
354
358
|
(position: number, itemSize = 1, padding = 0, totalSize?: number) => {
|
|
355
359
|
const currentOffset = offsetRef.current
|
|
360
|
+
const currentViewportSize = viewportSizeRef.current
|
|
356
361
|
const itemStart = position * itemSize
|
|
357
362
|
const itemEnd = itemStart + itemSize
|
|
358
363
|
// Use provided totalSize if available (more accurate than potentially stale contentSize)
|
|
@@ -363,12 +368,12 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
363
368
|
setOffset(Math.max(0, itemStart - padding))
|
|
364
369
|
}
|
|
365
370
|
// If item is below viewport, scroll down to show it
|
|
366
|
-
else if (itemEnd > currentOffset +
|
|
367
|
-
const currentMaxOffset = Math.max(0, effectiveContentSize -
|
|
368
|
-
setOffset(Math.min(currentMaxOffset, itemEnd -
|
|
371
|
+
else if (itemEnd > currentOffset + currentViewportSize - padding) {
|
|
372
|
+
const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
|
|
373
|
+
setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
|
|
369
374
|
}
|
|
370
375
|
},
|
|
371
|
-
[
|
|
376
|
+
[contentSize, setOffset],
|
|
372
377
|
)
|
|
373
378
|
|
|
374
379
|
const state: ScrollState = {
|
package/src/hosts/box.ts
CHANGED
|
@@ -112,15 +112,17 @@ export class BoxHost extends SingleChildHost {
|
|
|
112
112
|
drawBorder(buffer, x, y, w, h, chars, borderStyle)
|
|
113
113
|
|
|
114
114
|
// Draw title on top border if present
|
|
115
|
-
if (this.title && w >=
|
|
115
|
+
if (this.title && w >= 7) {
|
|
116
116
|
const titleFg = toColorValue(this.titleColor) ?? borderFg
|
|
117
117
|
const titleStyle = palette.id({ fg: titleFg })
|
|
118
|
-
|
|
118
|
+
// Reserve: ┌─ (2) + space before (1) + space after (1) + ─┐ (2) = 6 chars
|
|
119
|
+
const maxTitleLen = w - 6
|
|
119
120
|
const displayTitle =
|
|
120
121
|
this.title.length > maxTitleLen ? this.title.slice(0, maxTitleLen - 1) + "…" : this.title
|
|
121
|
-
// Draw " Title " starting at x+2
|
|
122
|
+
// Draw " Title " starting at x+2, limiting width to just the title text
|
|
122
123
|
const titleX = x + 2
|
|
123
|
-
|
|
124
|
+
const titleText = ` ${displayTitle} `
|
|
125
|
+
buffer.drawText(titleX, y, titleText, titleStyle, titleText.length)
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
|
package/src/motion/index.ts
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// useSequence - Timed discrete state sequences (motion.dev-style keyframes)
|
|
2
|
+
// For animating through discrete values like emojis, strings, or any non-numeric state
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from "react"
|
|
5
|
+
|
|
6
|
+
export interface SequenceOptions<T> {
|
|
7
|
+
/** Array of values to animate through */
|
|
8
|
+
keyframes: T[]
|
|
9
|
+
/** Position of each keyframe as 0-1 fraction (must match keyframes length) */
|
|
10
|
+
times: number[]
|
|
11
|
+
/** Total duration in milliseconds */
|
|
12
|
+
duration: number
|
|
13
|
+
/** Start automatically (default: true) */
|
|
14
|
+
autoPlay?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Animate through a sequence of discrete values with precise timing.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* // Emoji sequence: camera → flash → camera
|
|
23
|
+
* const emoji = useSequence({
|
|
24
|
+
* keyframes: ["📷", "📸", "📷"],
|
|
25
|
+
* times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
|
|
26
|
+
* duration: 2500,
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* return <text>{emoji}</text>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function useSequence<T>(options: SequenceOptions<T>): T {
|
|
33
|
+
const { keyframes, times, duration, autoPlay = true } = options
|
|
34
|
+
const [index, setIndex] = useState(0)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!autoPlay || keyframes.length <= 1) return
|
|
38
|
+
|
|
39
|
+
const timers: NodeJS.Timeout[] = []
|
|
40
|
+
|
|
41
|
+
// Schedule each keyframe transition
|
|
42
|
+
for (let i = 1; i < keyframes.length; i++) {
|
|
43
|
+
const delayMs = times[i]! * duration
|
|
44
|
+
timers.push(setTimeout(() => setIndex(i), delayMs))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return () => timers.forEach(clearTimeout)
|
|
48
|
+
}, [keyframes, times, duration, autoPlay])
|
|
49
|
+
|
|
50
|
+
return keyframes[index]!
|
|
51
|
+
}
|