@effect-tui/react 0.9.0 → 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 +3 -1
- package/dist/src/dev/Toast.d.ts.map +1 -1
- package/dist/src/dev/Toast.js +37 -3
- 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 +58 -4
- 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
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
// Beautiful, minimal notifications that appear at the top of the screen
|
|
3
3
|
|
|
4
4
|
import { Colors } from "@effect-tui/core"
|
|
5
|
-
import { createContext, type ReactNode, useCallback, useContext, useState } from "react"
|
|
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
|
|
9
11
|
// ─────────────────────────────────────────────────────────────
|
|
10
12
|
|
|
11
|
-
export type ToastType = "success" | "info" | "warning" | "error"
|
|
13
|
+
export type ToastType = "success" | "info" | "warning" | "error" | "screenshot"
|
|
12
14
|
|
|
13
15
|
export interface Toast {
|
|
14
16
|
id: number
|
|
15
17
|
message: string
|
|
16
18
|
type: ToastType
|
|
19
|
+
/** Timestamp when the toast was created (for animations) */
|
|
20
|
+
createdAt: number
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ToastContextValue {
|
|
@@ -48,6 +52,7 @@ const TOAST_STYLES: Record<
|
|
|
48
52
|
info: { bg: Colors.rgb(30, 50, 80), fg: Colors.rgb(140, 180, 230), icon: "ℹ" },
|
|
49
53
|
warning: { bg: Colors.rgb(80, 60, 20), fg: Colors.rgb(230, 200, 100), icon: "⚠" },
|
|
50
54
|
error: { bg: Colors.rgb(80, 30, 30), fg: Colors.rgb(230, 140, 140), icon: "✗" },
|
|
55
|
+
screenshot: { bg: Colors.rgb(30, 70, 40), fg: Colors.rgb(140, 230, 140), icon: "📷" },
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -61,7 +66,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
61
66
|
|
|
62
67
|
const show = useCallback((message: string, type: ToastType = "info", durationMs = 2000) => {
|
|
63
68
|
const id = ++toastId
|
|
64
|
-
setToasts((prev) => [...prev, { id, message, type }])
|
|
69
|
+
setToasts((prev) => [...prev, { id, message, type, createdAt: Date.now() }])
|
|
65
70
|
|
|
66
71
|
setTimeout(() => {
|
|
67
72
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
@@ -75,6 +80,49 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
75
80
|
return <ToastContext.Provider value={{ toasts, show, dismiss }}>{children}</ToastContext.Provider>
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
// ─────────────────────────────────────────────────────────────
|
|
84
|
+
// Screenshot Toast Animation
|
|
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 }
|
|
91
|
+
|
|
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
|
+
})
|
|
99
|
+
|
|
100
|
+
// Background: bright flash → fade to green
|
|
101
|
+
const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
|
|
102
|
+
|
|
103
|
+
// Trigger fade after flash phase
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
setBg(SUCCESS_COLOR)
|
|
107
|
+
}, 200) // Start fade at 200ms
|
|
108
|
+
return () => clearTimeout(timer)
|
|
109
|
+
}, [setBg])
|
|
110
|
+
|
|
111
|
+
const bgValue = bg.get()
|
|
112
|
+
const isFlash = emoji === "📸"
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<hstack>
|
|
116
|
+
<spacer />
|
|
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>
|
|
121
|
+
</box>
|
|
122
|
+
</hstack>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
// ─────────────────────────────────────────────────────────────
|
|
79
127
|
// Toast Display Component
|
|
80
128
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -86,6 +134,12 @@ export function ToastContainer() {
|
|
|
86
134
|
|
|
87
135
|
// Show only the most recent toast
|
|
88
136
|
const toast = toasts[toasts.length - 1]
|
|
137
|
+
|
|
138
|
+
// Special animated toast for screenshots
|
|
139
|
+
if (toast.type === "screenshot") {
|
|
140
|
+
return <ScreenshotToast message={toast.message} />
|
|
141
|
+
}
|
|
142
|
+
|
|
89
143
|
const style = TOAST_STYLES[toast.type]
|
|
90
144
|
|
|
91
145
|
// Compact pill in upper right
|
|
@@ -111,7 +165,7 @@ export function useScreenshotToast() {
|
|
|
111
165
|
|
|
112
166
|
return useCallback(
|
|
113
167
|
(path: string) => {
|
|
114
|
-
show(`Screenshot
|
|
168
|
+
show(`Screenshot copied!`, "screenshot", 2500)
|
|
115
169
|
},
|
|
116
170
|
[show],
|
|
117
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
|
+
}
|