fix: update theme preview on focus change (#562)
Treat default select focus as initial state so /theme and first-run previews follow keyboard navigation again. Co-authored-by: anandh8x <test@example.com>
This commit is contained in:
@@ -285,7 +285,7 @@ export function Select(t0) {
|
|||||||
onChange,
|
onChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
onFocus,
|
onFocus,
|
||||||
focusValue: defaultFocusValue
|
defaultFocusValue,
|
||||||
};
|
};
|
||||||
$[7] = defaultFocusValue;
|
$[7] = defaultFocusValue;
|
||||||
$[8] = defaultValue;
|
$[8] = defaultValue;
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export type UseSelectStateProps<T> = {
|
|||||||
*/
|
*/
|
||||||
onFocus?: (value: T) => void
|
onFocus?: (value: T) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value to focus when the component mounts.
|
||||||
|
*/
|
||||||
|
defaultFocusValue?: T
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value to focus
|
* Value to focus
|
||||||
*/
|
*/
|
||||||
@@ -131,6 +136,7 @@ export function useSelectState<T>({
|
|||||||
onChange,
|
onChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
defaultFocusValue,
|
||||||
focusValue,
|
focusValue,
|
||||||
}: UseSelectStateProps<T>): SelectState<T> {
|
}: UseSelectStateProps<T>): SelectState<T> {
|
||||||
const [value, setValue] = useState<T | undefined>(defaultValue)
|
const [value, setValue] = useState<T | undefined>(defaultValue)
|
||||||
@@ -138,7 +144,7 @@ export function useSelectState<T>({
|
|||||||
const navigation = useSelectNavigation<T>({
|
const navigation = useSelectNavigation<T>({
|
||||||
visibleOptionCount,
|
visibleOptionCount,
|
||||||
options,
|
options,
|
||||||
initialFocusValue: undefined,
|
initialFocusValue: defaultFocusValue,
|
||||||
onFocus,
|
onFocus,
|
||||||
focusValue,
|
focusValue,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,113 +1,161 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test'
|
import { PassThrough } from 'node:stream'
|
||||||
|
|
||||||
// We can't fully render ThemePicker due to complex dependencies
|
import { afterEach, expect, mock, test } from 'bun:test'
|
||||||
// But we can test the theme options generation logic
|
import React from 'react'
|
||||||
describe('ThemePicker', () => {
|
import stripAnsi from 'strip-ansi'
|
||||||
describe('theme options', () => {
|
|
||||||
it('generates correct theme options without AUTO_THEME feature flag', () => {
|
|
||||||
// Since we can't easily mock bun:bundle, test the options structure
|
|
||||||
// The real test would require integration testing
|
|
||||||
const expectedOptions = [
|
|
||||||
{ label: "Dark mode", value: "dark" },
|
|
||||||
{ label: "Light mode", value: "light" },
|
|
||||||
{ label: "Dark mode (colorblind-friendly)", value: "dark-daltonized" },
|
|
||||||
{ label: "Light mode (colorblind-friendly)", value: "light-daltonized" },
|
|
||||||
{ label: "Dark mode (ANSI colors only)", value: "dark-ansi" },
|
|
||||||
{ label: "Light mode (ANSI colors only)", value: "light-ansi" },
|
|
||||||
]
|
|
||||||
expect(expectedOptions.length).toBe(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes auto theme when AUTO_THEME feature is enabled', () => {
|
import { createRoot, Text, useTheme } from '../ink.js'
|
||||||
// Test the structure when auto is present
|
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
||||||
const optionsWithAuto = [
|
import { AppStateProvider } from '../state/AppState.js'
|
||||||
{ label: "Auto (match terminal)", value: "auto" },
|
import { ThemeProvider } from './design-system/ThemeProvider.js'
|
||||||
{ label: "Dark mode", value: "dark" },
|
|
||||||
]
|
|
||||||
expect(optionsWithAuto[0].value).toBe('auto')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('handleRowFocus callback', () => {
|
mock.module('./StructuredDiff.js', () => ({
|
||||||
it('setPreviewTheme is called with theme setting', () => {
|
StructuredDiff: function StructuredDiffPreview(): React.ReactNode {
|
||||||
const setPreviewTheme = mock()
|
const [theme] = useTheme()
|
||||||
const handleRowFocus = (setting: string) => setPreviewTheme(setting)
|
return <Text>{`Preview theme: ${theme}`}</Text>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
handleRowFocus('dark')
|
mock.module('./StructuredDiff/colorDiff.js', () => ({
|
||||||
expect(setPreviewTheme).toHaveBeenCalledWith('dark')
|
getColorModuleUnavailableReason: () => 'env',
|
||||||
})
|
getSyntaxTheme: () => null,
|
||||||
})
|
}))
|
||||||
|
|
||||||
describe('handleSelect callback', () => {
|
const SYNC_START = '\x1B[?2026h'
|
||||||
it('calls savePreview and onThemeSelect', () => {
|
const SYNC_END = '\x1B[?2026l'
|
||||||
const savePreview = mock()
|
|
||||||
const onThemeSelect = mock()
|
function extractLastFrame(output: string): string {
|
||||||
const handleSelect = (setting: string) => {
|
let lastFrame: string | null = null
|
||||||
savePreview()
|
let cursor = 0
|
||||||
onThemeSelect(setting)
|
|
||||||
|
while (cursor < output.length) {
|
||||||
|
const start = output.indexOf(SYNC_START, cursor)
|
||||||
|
if (start === -1) {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSelect('light')
|
const contentStart = start + SYNC_START.length
|
||||||
expect(savePreview).toHaveBeenCalled()
|
const end = output.indexOf(SYNC_END, contentStart)
|
||||||
expect(onThemeSelect).toHaveBeenCalledWith('light')
|
if (end === -1) {
|
||||||
})
|
break
|
||||||
})
|
|
||||||
|
|
||||||
describe('handleCancel callback', () => {
|
|
||||||
it('calls cancelPreview and gracefulShutdown when not skipExitHandling', () => {
|
|
||||||
const cancelPreview = mock()
|
|
||||||
const gracefulShutdown = mock()
|
|
||||||
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
|
|
||||||
cancelPreview()
|
|
||||||
if (skipExitHandling) {
|
|
||||||
onCancelProp?.()
|
|
||||||
} else {
|
|
||||||
gracefulShutdown(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancel(false)
|
const frame = output.slice(contentStart, end)
|
||||||
expect(cancelPreview).toHaveBeenCalled()
|
if (frame.trim().length > 0) {
|
||||||
expect(gracefulShutdown).toHaveBeenCalledWith(0)
|
lastFrame = frame
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onCancelProp when skipExitHandling is true', () => {
|
|
||||||
const cancelPreview = mock()
|
|
||||||
const onCancelProp = mock()
|
|
||||||
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
|
|
||||||
cancelPreview()
|
|
||||||
if (skipExitHandling) {
|
|
||||||
onCancelProp?.()
|
|
||||||
}
|
}
|
||||||
|
cursor = end + SYNC_END.length
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancel(true, onCancelProp)
|
return lastFrame ?? output
|
||||||
expect(cancelPreview).toHaveBeenCalled()
|
}
|
||||||
expect(onCancelProp).toHaveBeenCalled()
|
|
||||||
})
|
function createTestStreams(): {
|
||||||
|
stdout: PassThrough
|
||||||
|
stdin: PassThrough & {
|
||||||
|
isTTY: boolean
|
||||||
|
setRawMode: (mode: boolean) => void
|
||||||
|
ref: () => void
|
||||||
|
unref: () => void
|
||||||
|
}
|
||||||
|
getOutput: () => string
|
||||||
|
} {
|
||||||
|
let output = ''
|
||||||
|
const stdout = new PassThrough()
|
||||||
|
const stdin = new PassThrough() as PassThrough & {
|
||||||
|
isTTY: boolean
|
||||||
|
setRawMode: (mode: boolean) => void
|
||||||
|
ref: () => void
|
||||||
|
unref: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin.isTTY = true
|
||||||
|
stdin.setRawMode = () => {}
|
||||||
|
stdin.ref = () => {}
|
||||||
|
stdin.unref = () => {}
|
||||||
|
;(stdout as unknown as { columns: number }).columns = 120
|
||||||
|
stdout.on('data', chunk => {
|
||||||
|
output += chunk.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('syntax hint logic', () => {
|
return {
|
||||||
it('shows disabled hint when syntax highlighting is disabled', () => {
|
stdout,
|
||||||
const syntaxHighlightingDisabled = true
|
stdin,
|
||||||
const syntaxToggleShortcut = 'Ctrl+T'
|
getOutput: () => output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hint = syntaxHighlightingDisabled
|
async function waitForCondition(
|
||||||
? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
|
predicate: () => boolean,
|
||||||
: `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
|
timeoutMs = 2000,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
expect(hint).toContain('disabled')
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (predicate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out waiting for ThemePicker test condition')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFrame(
|
||||||
|
getOutput: () => string,
|
||||||
|
predicate: (frame: string) => boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
let frame = ''
|
||||||
|
|
||||||
|
await waitForCondition(() => {
|
||||||
|
frame = stripAnsi(extractLastFrame(getOutput()))
|
||||||
|
return predicate(frame)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows enabled hint when syntax highlighting is active', () => {
|
return frame
|
||||||
const syntaxHighlightingDisabled = false
|
}
|
||||||
const syntaxToggleShortcut = 'Ctrl+T'
|
|
||||||
|
|
||||||
const hint = !syntaxHighlightingDisabled
|
afterEach(() => {
|
||||||
? `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
|
mock.restore()
|
||||||
: `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
|
})
|
||||||
|
|
||||||
expect(hint).toContain('enabled')
|
test('updates the preview when keyboard focus moves to another theme', async () => {
|
||||||
})
|
const { ThemePicker } = await import('./ThemePicker.js')
|
||||||
})
|
const { stdout, stdin, getOutput } = createTestStreams()
|
||||||
|
const root = await createRoot({
|
||||||
|
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||||
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||||
|
patchConsole: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<AppStateProvider>
|
||||||
|
<KeybindingSetup>
|
||||||
|
<ThemeProvider initialState="dark">
|
||||||
|
<ThemePicker onThemeSelect={() => {}} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</KeybindingSetup>
|
||||||
|
</AppStateProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initialFrame = await waitForFrame(
|
||||||
|
getOutput,
|
||||||
|
frame => frame.includes('Preview theme: dark'),
|
||||||
|
)
|
||||||
|
expect(initialFrame).toContain('Preview theme: dark')
|
||||||
|
|
||||||
|
stdin.write('j')
|
||||||
|
|
||||||
|
const updatedFrame = await waitForFrame(
|
||||||
|
getOutput,
|
||||||
|
frame => frame.includes('Preview theme: light'),
|
||||||
|
)
|
||||||
|
expect(updatedFrame).toContain('Preview theme: light')
|
||||||
|
} finally {
|
||||||
|
root.unmount()
|
||||||
|
stdin.end()
|
||||||
|
stdout.end()
|
||||||
|
await Bun.sleep(0)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user