termcast 1.3.30 → 1.3.32
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/apis/cache.d.ts.map +1 -1
- package/dist/apis/cache.js +4 -39
- package/dist/apis/cache.js.map +1 -1
- package/dist/apis/hud.d.ts.map +1 -1
- package/dist/apis/hud.js +13 -31
- package/dist/apis/hud.js.map +1 -1
- package/dist/apis/localstorage.d.ts.map +1 -1
- package/dist/apis/localstorage.js +3 -27
- package/dist/apis/localstorage.js.map +1 -1
- package/dist/apis/toast.d.ts +16 -43
- package/dist/apis/toast.d.ts.map +1 -1
- package/dist/apis/toast.js +78 -177
- package/dist/apis/toast.js.map +1 -1
- package/dist/build.d.ts +3 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +52 -2
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +206 -25
- package/dist/cli.js.map +1 -1
- package/dist/colors.d.ts.map +1 -1
- package/dist/colors.js +1 -0
- package/dist/colors.js.map +1 -1
- package/dist/compile.d.ts +0 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +18 -23
- package/dist/compile.js.map +1 -1
- package/dist/components/actions.d.ts.map +1 -1
- package/dist/components/actions.js +30 -15
- package/dist/components/actions.js.map +1 -1
- package/dist/components/animation-tick.d.ts +12 -0
- package/dist/components/animation-tick.d.ts.map +1 -0
- package/dist/components/animation-tick.js +63 -0
- package/dist/components/animation-tick.js.map +1 -0
- package/dist/components/detail.d.ts.map +1 -1
- package/dist/components/detail.js +10 -13
- package/dist/components/detail.js.map +1 -1
- package/dist/components/dropdown.d.ts +1 -0
- package/dist/components/dropdown.d.ts.map +1 -1
- package/dist/components/dropdown.js +27 -26
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/extension-preferences.d.ts.map +1 -1
- package/dist/components/extension-preferences.js +15 -10
- package/dist/components/extension-preferences.js.map +1 -1
- package/dist/components/footer.d.ts +13 -0
- package/dist/components/footer.d.ts.map +1 -0
- package/dist/components/footer.js +106 -0
- package/dist/components/footer.js.map +1 -0
- package/dist/components/form/file-autocomplete.d.ts +19 -4
- package/dist/components/form/file-autocomplete.d.ts.map +1 -1
- package/dist/components/form/file-autocomplete.js +56 -55
- package/dist/components/form/file-autocomplete.js.map +1 -1
- package/dist/components/form/file-picker.d.ts.map +1 -1
- package/dist/components/form/file-picker.js +26 -15
- package/dist/components/form/file-picker.js.map +1 -1
- package/dist/components/form/index.d.ts.map +1 -1
- package/dist/components/form/index.js +17 -15
- package/dist/components/form/index.js.map +1 -1
- package/dist/components/form/with-left-border.d.ts.map +1 -1
- package/dist/components/form/with-left-border.js +4 -12
- package/dist/components/form/with-left-border.js.map +1 -1
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +126 -86
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-bar.d.ts.map +1 -1
- package/dist/components/loading-bar.js +5 -22
- package/dist/components/loading-bar.js.map +1 -1
- package/dist/components/loading-text.d.ts.map +1 -1
- package/dist/components/loading-text.js +3 -22
- package/dist/components/loading-text.js.map +1 -1
- package/dist/components/theme-picker.d.ts +2 -0
- package/dist/components/theme-picker.d.ts.map +1 -0
- package/dist/components/theme-picker.js +37 -0
- package/dist/components/theme-picker.js.map +1 -0
- package/dist/descendants.d.ts +6 -0
- package/dist/descendants.d.ts.map +1 -1
- package/dist/descendants.js +74 -8
- package/dist/descendants.js.map +1 -1
- package/dist/examples/internal/descendants-rerender.d.ts +14 -0
- package/dist/examples/internal/descendants-rerender.d.ts.map +1 -0
- package/dist/examples/internal/descendants-rerender.js +145 -0
- package/dist/examples/internal/descendants-rerender.js.map +1 -0
- package/dist/examples/internal/simple-dialog.js +4 -1
- package/dist/examples/internal/simple-dialog.js.map +1 -1
- package/dist/examples/internal/simple-scrollbox.js +1 -1
- package/dist/examples/internal/simple-scrollbox.js.map +1 -1
- package/dist/examples/list-with-dropdown.js +1 -1
- package/dist/examples/list-with-dropdown.js.map +1 -1
- package/dist/examples/miscellaneous.js +1 -1
- package/dist/examples/miscellaneous.js.map +1 -1
- package/dist/examples/toast-action.d.ts +2 -0
- package/dist/examples/toast-action.d.ts.map +1 -0
- package/dist/examples/toast-action.js +76 -0
- package/dist/examples/toast-action.js.map +1 -0
- package/dist/examples/toast-variations.js +38 -36
- package/dist/examples/toast-variations.js.map +1 -1
- package/dist/extensions/dev.d.ts +1 -1
- package/dist/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +62 -30
- package/dist/extensions/dev.js.map +1 -1
- package/dist/extensions/home.d.ts.map +1 -1
- package/dist/extensions/home.js +4 -3
- package/dist/extensions/home.js.map +1 -1
- package/dist/extensions/react-refresh-init.d.ts +5 -0
- package/dist/extensions/react-refresh-init.d.ts.map +1 -0
- package/dist/extensions/react-refresh-init.js +52 -0
- package/dist/extensions/react-refresh-init.js.map +1 -0
- package/dist/internal/date-picker-widget.js +1 -1
- package/dist/internal/date-picker-widget.js.map +1 -1
- package/dist/internal/dialog.d.ts +8 -3
- package/dist/internal/dialog.d.ts.map +1 -1
- package/dist/internal/dialog.js +37 -53
- package/dist/internal/dialog.js.map +1 -1
- package/dist/internal/navigation.d.ts +1 -0
- package/dist/internal/navigation.d.ts.map +1 -1
- package/dist/internal/navigation.js +25 -1
- package/dist/internal/navigation.js.map +1 -1
- package/dist/internal/providers.d.ts.map +1 -1
- package/dist/internal/providers.js +9 -197
- package/dist/internal/providers.js.map +1 -1
- package/dist/internal/scrollbox.d.ts.map +1 -1
- package/dist/internal/scrollbox.js +1 -0
- package/dist/internal/scrollbox.js.map +1 -1
- package/dist/release.d.ts +1 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +16 -9
- package/dist/release.js.map +1 -1
- package/dist/state.d.ts +27 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +6 -0
- package/dist/state.js.map +1 -1
- package/dist/theme.d.ts +6 -19
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +76 -45
- package/dist/theme.js.map +1 -1
- package/dist/themes/aura.json +69 -0
- package/dist/themes/ayu.json +80 -0
- package/dist/themes/catppuccin-frappe.json +233 -0
- package/dist/themes/catppuccin-macchiato.json +233 -0
- package/dist/themes/catppuccin.json +112 -0
- package/dist/themes/cobalt2.json +228 -0
- package/dist/themes/cursor.json +249 -0
- package/dist/themes/dracula.json +219 -0
- package/dist/themes/everforest.json +241 -0
- package/dist/themes/flexoki.json +237 -0
- package/dist/themes/github-light.json +56 -0
- package/dist/themes/github.json +241 -0
- package/dist/themes/gruvbox.json +95 -0
- package/dist/themes/kanagawa.json +77 -0
- package/dist/themes/lucent-orng.json +227 -0
- package/dist/themes/material.json +235 -0
- package/dist/themes/matrix.json +77 -0
- package/dist/themes/mercury.json +245 -0
- package/dist/themes/monokai.json +221 -0
- package/dist/themes/nightowl.json +221 -0
- package/dist/themes/nord.json +223 -0
- package/dist/themes/one-dark.json +84 -0
- package/dist/themes/opencode-light.json +62 -0
- package/dist/themes/opencode.json +245 -0
- package/dist/themes/orng.json +245 -0
- package/dist/themes/palenight.json +222 -0
- package/dist/themes/rosepine.json +234 -0
- package/dist/themes/solarized.json +223 -0
- package/dist/themes/synthwave84.json +226 -0
- package/dist/themes/termcast.json +226 -0
- package/dist/themes/tokyonight.json +243 -0
- package/dist/themes/vercel.json +255 -0
- package/dist/themes/vesper.json +218 -0
- package/dist/themes/zenburn.json +223 -0
- package/dist/themes.d.ts +57 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +181 -0
- package/dist/themes.js.map +1 -0
- package/dist/utils/run-command.d.ts +2 -1
- package/dist/utils/run-command.d.ts.map +1 -1
- package/dist/utils/run-command.js +20 -10
- package/dist/utils/run-command.js.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +90 -17
- package/dist/utils.js.map +1 -1
- package/dist/watcher.d.ts +3 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +16 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +16 -10
- package/src/apis/cache.tsx +5 -44
- package/src/apis/hud.tsx +17 -62
- package/src/apis/localstorage.tsx +3 -32
- package/src/apis/toast.tsx +91 -275
- package/src/build.test.tsx +10 -0
- package/src/build.tsx +61 -1
- package/src/cli.tsx +365 -103
- package/src/colors.tsx +1 -0
- package/src/compile.tsx +21 -29
- package/src/compile.vitest.tsx +300 -0
- package/src/components/actions.tsx +64 -45
- package/src/components/animation-tick.tsx +85 -0
- package/src/components/detail.tsx +31 -35
- package/src/components/dropdown.tsx +32 -21
- package/src/components/extension-preferences.tsx +14 -10
- package/src/components/footer.tsx +241 -0
- package/src/components/form/file-autocomplete.tsx +80 -60
- package/src/components/form/file-picker.tsx +37 -25
- package/src/components/form/index.tsx +45 -41
- package/src/components/form/with-left-border.tsx +4 -14
- package/src/components/list.tsx +181 -121
- package/src/components/loading-bar.tsx +5 -25
- package/src/components/loading-text.tsx +4 -23
- package/src/components/theme-picker.tsx +57 -0
- package/src/descendants.tsx +98 -9
- package/src/examples/actions-dialog-layout.vitest.tsx +112 -0
- package/src/examples/file-autocomplete.vitest.tsx +131 -122
- package/src/examples/form-basic.vitest.tsx +463 -644
- package/src/examples/form-dropdown.vitest.tsx +553 -571
- package/src/examples/form-scroll.vitest.tsx +112 -102
- package/src/examples/form-tagpicker.vitest.tsx +364 -338
- package/src/examples/internal/descendants-rerender.tsx +273 -0
- package/src/examples/internal/descendants-rerender.vitest.tsx +194 -0
- package/src/examples/internal/simple-dialog.tsx +4 -4
- package/src/examples/internal/simple-scrollbox.tsx +2 -2
- package/src/examples/internal/simple-scrollbox.vitest.tsx +43 -31
- package/src/examples/list-detail-metadata.vitest.tsx +34 -30
- package/src/examples/list-dropdown-default.vitest.tsx +84 -72
- package/src/examples/list-empty-view.vitest.tsx +93 -0
- package/src/examples/list-fetch-data.vitest.tsx +36 -30
- package/src/examples/list-scrollbox.vitest.tsx +59 -39
- package/src/examples/list-with-detail.vitest.tsx +339 -314
- package/src/examples/list-with-dropdown.tsx +1 -0
- package/src/examples/list-with-dropdown.vitest.tsx +176 -150
- package/src/examples/list-with-sections.vitest.tsx +289 -270
- package/src/examples/list-with-toast.vitest.tsx +44 -44
- package/src/examples/miscellaneous.tsx +10 -0
- package/src/examples/simple-file-picker.vitest.tsx +90 -86
- package/src/examples/simple-grid.vitest.tsx +275 -249
- package/src/examples/simple-navigation.vitest.tsx +192 -168
- package/src/examples/store.vitest.tsx +6 -4
- package/src/examples/swift-extension.vitest.tsx +31 -19
- package/src/examples/synonyms.vitest.tsx +93 -83
- package/src/examples/toast-action.tsx +160 -0
- package/src/examples/toast-action.vitest.tsx +404 -0
- package/src/examples/toast-variations.tsx +58 -57
- package/src/examples/toast-variations.vitest.tsx +186 -166
- package/src/extensions/dev.tsx +74 -33
- package/src/extensions/dev.vitest.tsx +162 -69
- package/src/extensions/home.tsx +5 -6
- package/src/extensions/react-refresh-init.tsx +59 -0
- package/src/internal/date-picker-widget.tsx +1 -1
- package/src/internal/dialog.tsx +59 -83
- package/src/internal/navigation.tsx +37 -4
- package/src/internal/providers.tsx +27 -315
- package/src/internal/scrollbox.tsx +1 -0
- package/src/release.tsx +16 -10
- package/src/state.tsx +36 -3
- package/src/theme.tsx +82 -51
- package/src/themes/aura.json +69 -0
- package/src/themes/ayu.json +80 -0
- package/src/themes/catppuccin-frappe.json +233 -0
- package/src/themes/catppuccin-macchiato.json +233 -0
- package/src/themes/catppuccin.json +112 -0
- package/src/themes/cobalt2.json +228 -0
- package/src/themes/cursor.json +249 -0
- package/src/themes/dracula.json +219 -0
- package/src/themes/everforest.json +241 -0
- package/src/themes/flexoki.json +237 -0
- package/src/themes/github-light.json +56 -0
- package/src/themes/github.json +241 -0
- package/src/themes/gruvbox.json +95 -0
- package/src/themes/kanagawa.json +77 -0
- package/src/themes/lucent-orng.json +227 -0
- package/src/themes/material.json +235 -0
- package/src/themes/matrix.json +77 -0
- package/src/themes/mercury.json +252 -0
- package/src/themes/monokai.json +221 -0
- package/src/themes/nightowl.json +221 -0
- package/src/themes/nord.json +223 -0
- package/src/themes/one-dark.json +84 -0
- package/src/themes/opencode-light.json +62 -0
- package/src/themes/opencode.json +245 -0
- package/src/themes/orng.json +245 -0
- package/src/themes/palenight.json +222 -0
- package/src/themes/rosepine.json +234 -0
- package/src/themes/solarized.json +223 -0
- package/src/themes/synthwave84.json +226 -0
- package/src/themes/termcast.json +227 -0
- package/src/themes/tokyonight.json +243 -0
- package/src/themes/vercel.json +255 -0
- package/src/themes/vesper.json +218 -0
- package/src/themes/zenburn.json +223 -0
- package/src/themes.ts +291 -0
- package/src/utils/run-command.tsx +23 -12
- package/src/utils.tsx +115 -18
- package/src/watcher.tsx +19 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react'
|
|
2
2
|
import { colord } from 'colord'
|
|
3
|
+
import { useAnimationTick, TICK_DIVISORS } from 'termcast/src/components/animation-tick'
|
|
3
4
|
|
|
4
5
|
interface LoadingTextProps {
|
|
5
6
|
children: string
|
|
@@ -21,8 +22,7 @@ function generateWaveColors(baseColor: string): string[] {
|
|
|
21
22
|
|
|
22
23
|
export function LoadingText(props: LoadingTextProps): any {
|
|
23
24
|
const { children, isLoading = false, color = '#FFC000' } = props
|
|
24
|
-
const
|
|
25
|
-
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
25
|
+
const tick = useAnimationTick(isLoading ? TICK_DIVISORS.LOADING_TEXT : 0)
|
|
26
26
|
|
|
27
27
|
const characters = children.split('')
|
|
28
28
|
const waveColors = generateWaveColors(color)
|
|
@@ -30,26 +30,7 @@ export function LoadingText(props: LoadingTextProps): any {
|
|
|
30
30
|
// Add padding at the end to create a delay before the next loop
|
|
31
31
|
const endPadding = 10
|
|
32
32
|
const totalLength = characters.length + waveWidth + endPadding
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
if (isLoading) {
|
|
36
|
-
intervalRef.current = setInterval(() => {
|
|
37
|
-
setPosition((prev) => (prev + 1) % totalLength)
|
|
38
|
-
}, 30)
|
|
39
|
-
} else {
|
|
40
|
-
if (intervalRef.current) {
|
|
41
|
-
clearInterval(intervalRef.current)
|
|
42
|
-
intervalRef.current = null
|
|
43
|
-
}
|
|
44
|
-
setPosition(0)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return () => {
|
|
48
|
-
if (intervalRef.current) {
|
|
49
|
-
clearInterval(intervalRef.current)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}, [isLoading, totalLength])
|
|
33
|
+
const position = isLoading ? tick % totalLength : 0
|
|
53
34
|
|
|
54
35
|
const getCharacterColor = (index: number): string => {
|
|
55
36
|
if (!isLoading) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { useKeyboard } from '@opentui/react'
|
|
3
|
+
import { Theme, persistTheme } from 'termcast/src/theme'
|
|
4
|
+
import { themeNames } from '../themes'
|
|
5
|
+
import { useStore } from 'termcast/src/state'
|
|
6
|
+
import { useDialog } from 'termcast/src/internal/dialog'
|
|
7
|
+
import { useIsInFocus } from 'termcast/src/internal/focus-context'
|
|
8
|
+
import { Dropdown } from 'termcast/src/components/dropdown'
|
|
9
|
+
|
|
10
|
+
export function ThemePicker(): any {
|
|
11
|
+
const dialog = useDialog()
|
|
12
|
+
const inFocus = useIsInFocus()
|
|
13
|
+
const currentThemeName = useStore((state) => state.currentThemeName)
|
|
14
|
+
const [previousTheme] = useState(currentThemeName)
|
|
15
|
+
|
|
16
|
+
const handleSelectionChange = (value: string) => {
|
|
17
|
+
// Preview theme on selection change
|
|
18
|
+
useStore.setState({ currentThemeName: value })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const handleChange = (value: string) => {
|
|
22
|
+
// Confirm selection
|
|
23
|
+
useStore.setState({ currentThemeName: value })
|
|
24
|
+
persistTheme(value)
|
|
25
|
+
dialog.clear()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
useKeyboard((evt) => {
|
|
29
|
+
if (!inFocus) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (evt.name === 'escape') {
|
|
33
|
+
// Revert to previous theme on escape
|
|
34
|
+
useStore.setState({ currentThemeName: previousTheme })
|
|
35
|
+
dialog.clear()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Dropdown
|
|
41
|
+
tooltip="Change Theme"
|
|
42
|
+
placeholder="Search themes..."
|
|
43
|
+
filtering
|
|
44
|
+
onChange={handleChange}
|
|
45
|
+
onSelectionChange={handleSelectionChange}
|
|
46
|
+
>
|
|
47
|
+
{themeNames.map((name) => (
|
|
48
|
+
<Dropdown.Item
|
|
49
|
+
key={name}
|
|
50
|
+
title={name}
|
|
51
|
+
value={name}
|
|
52
|
+
color={name === previousTheme ? Theme.primary : undefined}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</Dropdown>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/descendants.tsx
CHANGED
|
@@ -7,9 +7,16 @@ type DescendantMap<T> = { [id: string]: { index: number; props?: T } }
|
|
|
7
7
|
|
|
8
8
|
export interface DescendantContextType<T> {
|
|
9
9
|
getIndexForId: (id: string, props?: T) => number
|
|
10
|
-
// IMPORTANT! map is not reactive
|
|
10
|
+
// IMPORTANT! map is not reactive by default. Use useDescendantsRerender() to opt-in to reactivity.
|
|
11
|
+
// Without that hook, map can only be read in useEffect/useLayoutEffect or event handlers like useKeyboard
|
|
11
12
|
map: React.RefObject<DescendantMap<T>>
|
|
12
13
|
reset: () => void
|
|
14
|
+
// For useSyncExternalStore - opt-in reactivity
|
|
15
|
+
subscribe: (callback: () => void) => () => void
|
|
16
|
+
getSnapshot: () => string
|
|
17
|
+
updateSnapshot: () => void
|
|
18
|
+
// Committed map - stable copy for useDescendantsRerender (map.current is cleared on every render)
|
|
19
|
+
committedMap: DescendantMap<T>
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
const randomId = () => Math.random().toString(36).slice(2, 11)
|
|
@@ -26,6 +33,11 @@ export function createDescendants<T = any>() {
|
|
|
26
33
|
// On every re-render of children, reset the count
|
|
27
34
|
props.value.reset()
|
|
28
35
|
|
|
36
|
+
// Update snapshot after all children have registered (runs after children's useLayoutEffect)
|
|
37
|
+
React.useLayoutEffect(() => {
|
|
38
|
+
props.value.updateSnapshot()
|
|
39
|
+
})
|
|
40
|
+
|
|
29
41
|
return (
|
|
30
42
|
<DescendantContext.Provider value={props.value}>
|
|
31
43
|
{props.children}
|
|
@@ -37,30 +49,71 @@ export function createDescendants<T = any>() {
|
|
|
37
49
|
const indexCounter = React.useRef<number>(0)
|
|
38
50
|
const map = React.useRef<DescendantMap<T>>({})
|
|
39
51
|
|
|
52
|
+
// For useSyncExternalStore - opt-in reactivity
|
|
53
|
+
const listeners = React.useRef(new Set<() => void>())
|
|
54
|
+
const snapshotRef = React.useRef('')
|
|
55
|
+
const prevSnapshotRef = React.useRef('')
|
|
56
|
+
// Committed map - stable copy of map.current updated only when snapshot changes
|
|
57
|
+
// This is what useDescendantsRerender returns, since map.current is cleared on every render
|
|
58
|
+
const committedMapRef = React.useRef<DescendantMap<T>>({})
|
|
59
|
+
|
|
40
60
|
const reset = () => {
|
|
61
|
+
// Save previous snapshot before clearing
|
|
62
|
+
prevSnapshotRef.current = snapshotRef.current
|
|
41
63
|
indexCounter.current = 0
|
|
42
64
|
map.current = {}
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
const getIndexForId = (id: string, props?: T) => {
|
|
46
|
-
if (!map.current[id])
|
|
68
|
+
if (!map.current[id]) {
|
|
47
69
|
map.current[id] = {
|
|
48
70
|
index: indexCounter.current++,
|
|
49
71
|
}
|
|
72
|
+
}
|
|
50
73
|
map.current[id].props = props
|
|
51
74
|
return map.current[id].index
|
|
52
75
|
}
|
|
53
76
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
// Must be stable (memoized) for useSyncExternalStore
|
|
78
|
+
const subscribe = React.useCallback((callback: () => void) => {
|
|
79
|
+
listeners.current.add(callback)
|
|
80
|
+
return () => {
|
|
81
|
+
listeners.current.delete(callback)
|
|
82
|
+
}
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
// Must be stable for useSyncExternalStore
|
|
86
|
+
const getSnapshot = React.useCallback(() => snapshotRef.current, [])
|
|
87
|
+
|
|
88
|
+
// Called by provider after all children have registered
|
|
89
|
+
const updateSnapshot = React.useCallback(() => {
|
|
90
|
+
const newSnapshot = Object.keys(map.current).sort().join(',')
|
|
91
|
+
snapshotRef.current = newSnapshot
|
|
92
|
+
// Always update committed map so useDescendantsRerender returns fresh data
|
|
93
|
+
// (map.current is cleared by reset() on every render)
|
|
94
|
+
committedMapRef.current = { ...map.current }
|
|
95
|
+
// Only notify if there are listeners AND snapshot changed
|
|
96
|
+
if (listeners.current.size > 0 && newSnapshot !== prevSnapshotRef.current) {
|
|
97
|
+
listeners.current.forEach((cb) => {
|
|
98
|
+
cb()
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}, [])
|
|
59
102
|
|
|
60
103
|
// Do NOT memoize context value, so that we bypass React.memo on any children
|
|
61
104
|
// We NEED them to re-render, in case stable children were re-ordered
|
|
62
105
|
// (this creates a new object every render, so children reading the context MUST re-render)
|
|
63
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
getIndexForId,
|
|
108
|
+
map,
|
|
109
|
+
reset,
|
|
110
|
+
subscribe,
|
|
111
|
+
getSnapshot,
|
|
112
|
+
updateSnapshot,
|
|
113
|
+
get committedMap() {
|
|
114
|
+
return committedMapRef.current
|
|
115
|
+
},
|
|
116
|
+
}
|
|
64
117
|
}
|
|
65
118
|
|
|
66
119
|
/**
|
|
@@ -80,7 +133,43 @@ export function createDescendants<T = any>() {
|
|
|
80
133
|
return { descendantId, index }
|
|
81
134
|
}
|
|
82
135
|
|
|
83
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Opt-in to re-renders when the set of descendant IDs changes.
|
|
138
|
+
* Returns the committed map of descendants, readable during render.
|
|
139
|
+
* Only triggers re-render when descendants are added/removed, not on prop changes.
|
|
140
|
+
*/
|
|
141
|
+
function useDescendantsRerender(): DescendantMap<T> {
|
|
142
|
+
const context = React.useContext(DescendantContext)
|
|
143
|
+
if (!context) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'useDescendantsRerender must be used within a DescendantsProvider',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
React.useSyncExternalStore(
|
|
150
|
+
context.subscribe,
|
|
151
|
+
context.getSnapshot,
|
|
152
|
+
context.getSnapshot, // server snapshot
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return context.committedMap
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get the live map ref from context. Only read map.current inside useLayoutEffect,
|
|
160
|
+
* as it is cleared during render by reset() and populated by items' useLayoutEffect.
|
|
161
|
+
*/
|
|
162
|
+
function useDescendantsMap(): React.RefObject<DescendantMap<T>> {
|
|
163
|
+
const context = React.useContext(DescendantContext)
|
|
164
|
+
if (!context) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
'useDescendantsMap must be used within a DescendantsProvider',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
return context.map
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { DescendantsProvider, useDescendants, useDescendant, useDescendantsRerender, useDescendantsMap }
|
|
84
173
|
}
|
|
85
174
|
|
|
86
175
|
// EXAMPLE
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { test, expect, afterEach, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { launchTerminal, Session } from 'tuistory/src'
|
|
4
|
+
|
|
5
|
+
let session: Session
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
session = await launchTerminal({
|
|
9
|
+
command: 'bun',
|
|
10
|
+
args: ['src/examples/list-with-sections.tsx'],
|
|
11
|
+
cols: 70,
|
|
12
|
+
rows: 20,
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
session?.close()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('actions dialog layout shift when opening with ctrl+k', async () => {
|
|
21
|
+
// Wait for list to fully render
|
|
22
|
+
await session.text({
|
|
23
|
+
waitFor: (text) => /search/i.test(text) && /Apple/.test(text),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Capture multiple frames when pressing ctrl+k to verify no layout shift
|
|
27
|
+
// Previously, the dialog would shift position as content mounted incrementally.
|
|
28
|
+
// Now with fixed height on ScrollBox, the dialog appears at stable position immediately.
|
|
29
|
+
const frames = await session.captureFrames(['ctrl', 'k'], {
|
|
30
|
+
frameCount: 10,
|
|
31
|
+
intervalMs: 5,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Filter frames that show the dialog border
|
|
35
|
+
const dialogFrames = frames.filter((frame) => frame.includes('╭'))
|
|
36
|
+
|
|
37
|
+
// Find the line number where dialog border (╭) appears in each frame
|
|
38
|
+
const dialogPositions = dialogFrames.map((frame) => {
|
|
39
|
+
const lines = frame.split('\n')
|
|
40
|
+
return lines.findIndex((line) => line.includes('╭'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
console.log('Dialog border positions across frames:', dialogPositions)
|
|
44
|
+
|
|
45
|
+
// Check if all positions are the same (no layout shift)
|
|
46
|
+
const uniquePositions = [...new Set(dialogPositions)]
|
|
47
|
+
const hasLayoutShift = uniquePositions.length > 1
|
|
48
|
+
|
|
49
|
+
if (hasLayoutShift) {
|
|
50
|
+
console.log('LAYOUT SHIFT DETECTED!')
|
|
51
|
+
console.log(`Dialog moves from line ${Math.min(...dialogPositions)} to line ${Math.max(...dialogPositions)}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Capture first and last dialog frames to show the shift
|
|
55
|
+
const firstDialogFrame = dialogFrames[0]
|
|
56
|
+
const lastDialogFrame = dialogFrames[dialogFrames.length - 1]
|
|
57
|
+
|
|
58
|
+
// First frame: dialog appears at stable position (same as last frame)
|
|
59
|
+
expect(firstDialogFrame).toMatchInlineSnapshot(`
|
|
60
|
+
"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Simple List Example ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
╭────────────────────────────────────────────────────────────────╮
|
|
66
|
+
│ │
|
|
67
|
+
│ Actions esc │
|
|
68
|
+
│ │
|
|
69
|
+
│ > Search actions... │
|
|
70
|
+
│ │
|
|
71
|
+
│ ›View Details │
|
|
72
|
+
│ Add to Cart │
|
|
73
|
+
│ │
|
|
74
|
+
│ Settings │
|
|
75
|
+
│ Change Theme... │
|
|
76
|
+
│ │
|
|
77
|
+
│ │
|
|
78
|
+
│ │
|
|
79
|
+
│ ↵ select ↑↓ navigate │
|
|
80
|
+
│ │"
|
|
81
|
+
`)
|
|
82
|
+
|
|
83
|
+
// Last frame: dialog at same position as first frame (no shift)
|
|
84
|
+
expect(lastDialogFrame).toMatchInlineSnapshot(`
|
|
85
|
+
"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Simple List Example ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
╭────────────────────────────────────────────────────────────────╮
|
|
91
|
+
│ │
|
|
92
|
+
│ Actions esc │
|
|
93
|
+
│ │
|
|
94
|
+
│ > Search actions... │
|
|
95
|
+
│ │
|
|
96
|
+
│ ›View Details │
|
|
97
|
+
│ Add to Cart │
|
|
98
|
+
│ │
|
|
99
|
+
│ Settings │
|
|
100
|
+
│ Change Theme... │
|
|
101
|
+
│ │
|
|
102
|
+
│ │
|
|
103
|
+
│ │
|
|
104
|
+
│ ↵ select ↑↓ navigate │
|
|
105
|
+
│ │"
|
|
106
|
+
`)
|
|
107
|
+
|
|
108
|
+
// The dialog should appear at a stable position immediately.
|
|
109
|
+
// Fixed by using a fixed height on the ScrollBox instead of maxHeight,
|
|
110
|
+
// so the dialog has consistent dimensions from the first render.
|
|
111
|
+
expect(hasLayoutShift).toBe(false)
|
|
112
|
+
}, 15000)
|