Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/input-isolation-prompt-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@kidd-cli/core': minor
---

Replace implicit input gating with explicit `PromptProps` (`focused`, `disabled`)

- Add `PromptProps` interface with `focused` and `disabled` fields, shared by all prompt components
- Remove `InputBlock` / `useInputBlock` context-based input gating
- Remove `useFocus` from all prompt components (was conflicting with Tabs key interception)
- Remove `@inkjs/ui` dependency (no longer needed)
- Rename `isDisabled` to `disabled` across all prompts and stories
- Stories viewer passes `focused` explicitly to story components in preview mode
- `useInput` wrapper simplified to a direct proxy of ink's `useInput`
5 changes: 0 additions & 5 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@
"zod": "catalog:"
},
"devDependencies": {
"@inkjs/ui": "^2.0.0",
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/yargs": "^17.0.35",
Expand All @@ -100,17 +99,13 @@
"vitest": "catalog:"
},
"peerDependencies": {
"@inkjs/ui": ">=2.0.0",
"ink": ">=5.0.0",
"jiti": ">=2.0.0",
"pino": ">=9.0.0",
"react": ">=18.0.0",
"vitest": ">=2.0.0"
},
"peerDependenciesMeta": {
"@inkjs/ui": {
"optional": true
},
"jiti": {
"optional": true
},
Expand Down
39 changes: 20 additions & 19 deletions packages/core/src/stories/viewer/components/field-control.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Option } from '@inkjs/ui'
import { ConfirmInput, MultiSelect, Select, TextInput } from '@inkjs/ui'
import { Box, Text } from 'ink'
import type { ReactElement } from 'react'
import { match } from 'ts-pattern'

import { Confirm } from '../../../ui/prompts/confirm.js'
import { MultiSelect } from '../../../ui/prompts/multi-select.js'
import { Select } from '../../../ui/prompts/select.js'
import { TextInput } from '../../../ui/prompts/text-input.js'
import type { PromptOption } from '../../../ui/prompts/types.js'
import type { FieldControlKind } from '../../types.js'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -44,35 +47,31 @@ export function FieldControl({
.with('text', () => (
<TextInput
defaultValue={valueToString(value)}
isDisabled={!isFocused}
focused={isFocused}
onSubmit={(submitted) => onChange(submitted)}
/>
))
.with('number', () => (
<TextInput
defaultValue={valueToString(value)}
isDisabled={!isFocused}
focused={isFocused}
onSubmit={(submitted) => onChange(parseNumericValue(submitted))}
/>
))
.with('boolean', () => (
<Box>
<ConfirmInput
isDisabled={!isFocused}
defaultChoice={match(value)
.with(true, () => 'confirm' as const)
.otherwise(() => 'cancel' as const)}
onConfirm={() => onChange(true)}
onCancel={() => onChange(false)}
/>
<Text dimColor> (current: {String(value)})</Text>
</Box>
<Confirm
defaultValue={match(value)
.with(true, () => true)
.otherwise(() => false)}
focused={isFocused}
onSubmit={(submitted) => onChange(submitted)}
/>
))
.with('select', () => {
const selectOptions = buildSelectOptions(options)
return (
<Select
isDisabled={!isFocused}
focused={isFocused}
options={selectOptions}
onChange={(selected) => onChange(selected)}
/>
Expand All @@ -84,7 +83,7 @@ export function FieldControl({
return (
<Box flexDirection="column">
<MultiSelect
isDisabled={!isFocused}
focused={isFocused}
options={selectOptions}
defaultValue={defaultSelected}
onSubmit={(selectedValues) => onChange(selectedValues)}
Expand All @@ -96,7 +95,7 @@ export function FieldControl({
.with('json', () => (
<TextInput
defaultValue={stringifyJsonValue(value)}
isDisabled={!isFocused}
focused={isFocused}
onSubmit={(submitted) => onChange(parseJsonValue(submitted))}
/>
))
Expand Down Expand Up @@ -160,7 +159,9 @@ function parseNumericValue(input: string): number {
* @param options - The raw string options.
* @returns An array of label/value option objects.
*/
function buildSelectOptions(options: readonly string[] | undefined): Option[] {
function buildSelectOptions(
options: readonly string[] | undefined
): readonly PromptOption<string>[] {
if (options === undefined) {
return []
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/stories/viewer/components/help-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Box, Text, useInput } from 'ink'
import { Box, Text } from 'ink'
import type { ReactElement } from 'react'

import { useInput } from '../../../ui/use-input.js'

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/stories/viewer/components/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function Preview({
<Box ref={contentRef} flexDirection="column" flexGrow={1}>
<ScrollArea height={Math.max(1, componentAreaHeight)}>
<ErrorBoundary key={context.displayName}>
<DecoratedComponent {...currentProps} />
<DecoratedComponent {...currentProps} focused={false} />
</ErrorBoundary>
</ScrollArea>
<Box height={propsAreaHeight} overflow="hidden" flexDirection="column">
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/stories/viewer/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { hasTag } from '@kidd-cli/utils/tag'
import type { DOMElement } from 'ink'
import { Box, Text, useInput } from 'ink'
import { Box, Text } from 'ink'
import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { match } from 'ts-pattern'

import { ScrollArea } from '../../../ui/layout/scroll-area.js'
import { useSize } from '../../../ui/layout/use-size.js'
import { useInput } from '../../../ui/use-input.js'
import type { Story, StoryEntry, StoryGroup } from '../../types.js'

// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/viewer/hooks/use-double-escape.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useKeyBinding } from '../../../ui/use-key-binding.js'
import { useHotkey } from '../../../ui/use-key-binding.js'

// ---------------------------------------------------------------------------
// Types
Expand All @@ -25,5 +25,5 @@ interface DoubleEscapeOptions {
* @returns Nothing. Registers the key binding side effect.
*/
export function useDoubleEscape({ onExit, active }: DoubleEscapeOptions): void {
useKeyBinding({ keys: ['escape escape'], action: onExit, active })
useHotkey({ keys: ['escape escape'], action: onExit, active })
}
3 changes: 2 additions & 1 deletion packages/core/src/stories/viewer/stories-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { relative } from 'node:path'
import process from 'node:process'

import { hasTag } from '@kidd-cli/utils/tag'
import { Box, Text, useApp, useInput } from 'ink'
import { Box, Text, useApp } from 'ink'
import type { ReactElement } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { match } from 'ts-pattern'

import { FullScreen } from '../../ui/layout/fullscreen.js'
import { useInput } from '../../ui/use-input.js'
import type { StoryRegistry } from '../registry.js'
import { schemaToFieldDescriptors } from '../schema.js'
import type { Story, StoryEntry, StoryGroup } from '../types.js'
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// ---------------------------------------------------------------------------
// Base — raw Ink + @inkjs/ui primitives
// Base — raw Ink primitives
// ---------------------------------------------------------------------------

export {
Expand All @@ -16,7 +16,7 @@ export {
useCursor,
useFocus,
useFocusManager,
useInput,
useInput as useInkInput,
useIsScreenReaderEnabled,
useStderr,
useStdin,
Expand Down Expand Up @@ -62,6 +62,7 @@ export type {
PasswordInputProps,
PathInputProps,
PromptOption,
PromptProps,
SelectKeyProps,
SelectProps,
TextInputProps,
Expand Down Expand Up @@ -133,8 +134,8 @@ export type { ThemeColor, Variant } from './theme.js'
// Input
// ---------------------------------------------------------------------------

export { useKeyBinding } from './use-key-binding.js'
export type { UseKeyBindingArgs } from './use-key-binding.js'
export { useInput } from './use-input.js'
export type { UseInputOptions } from './use-input.js'

export { useKeyInput } from './use-key-input.js'
export type { KeyInputEvent, KeyInputOptions } from './use-key-input.js'
export { useHotkey } from './use-key-binding.js'
export type { UseHotkeyArgs } from './use-key-binding.js'
3 changes: 2 additions & 1 deletion packages/core/src/ui/layout/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Box, Text, useInput } from 'ink'
import { Box, Text } from 'ink'
import type { ReactElement, ReactNode } from 'react'
import { useState } from 'react'
import { match } from 'ts-pattern'

import { colors } from '../theme.js'
import { useInput } from '../use-input.js'

// ---------------------------------------------------------------------------
// Types
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/ui/prompts/autocomplete.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Autocomplete } from './autocomplete.js'
const schema = z.object({
placeholder: z.string().optional().describe('Placeholder text for the search input'),
maxVisible: z.number().optional().describe('Maximum visible options'),
isDisabled: z.boolean().optional().describe('Disable the component'),
disabled: z.boolean().optional().describe('Disable the component'),
})

const defaultOptions = [
Expand Down Expand Up @@ -49,7 +49,7 @@ const storyGroup: StoryGroup = stories({
description: 'Autocomplete with a custom starts-with filter.',
},
Disabled: {
props: { isDisabled: true },
props: { disabled: true },
description: 'Autocomplete in disabled state.',
},
},
Expand Down
43 changes: 21 additions & 22 deletions packages/core/src/ui/prompts/autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Box, Text, useInput } from 'ink'
import { Box, Text } from 'ink'
import type { ReactElement } from 'react'
import { useMemo, useState } from 'react'
import { match } from 'ts-pattern'

import { ScrollArea } from '../layout/scroll-area.js'
import { colors, symbols } from '../theme.js'
import { useInput } from '../use-input.js'
import { CursorValue } from './cursor-value.js'
import { resolveInitialIndex } from './navigation.js'
import { OptionRow } from './option-row.js'
import { insertCharAt, removeCharAt } from './string-utils.js'
import type { PromptOption } from './types.js'
import type { PromptOption, PromptProps } from './types.js'

// ---------------------------------------------------------------------------
// Types
Expand All @@ -18,7 +19,7 @@ import type { PromptOption } from './types.js'
/**
* Props for the {@link Autocomplete} component.
*/
export interface AutocompleteProps<TValue> {
export interface AutocompleteProps<TValue> extends PromptProps {
/** The full list of selectable options. */
readonly options: readonly PromptOption<TValue>[]

Expand All @@ -39,9 +40,6 @@ export interface AutocompleteProps<TValue> {

/** Called when the user presses Enter to confirm. */
readonly onSubmit?: (value: TValue) => void

/** When `true`, the component does not respond to input. */
readonly isDisabled?: boolean
}

// ---------------------------------------------------------------------------
Expand All @@ -67,7 +65,8 @@ export function Autocomplete<TValue>({
filter = defaultFilter,
onChange,
onSubmit,
isDisabled = false,
focused = true,
disabled = false,
}: AutocompleteProps<TValue>): ReactElement {
const [search, setSearch] = useState('')
const [focusIndex, setFocusIndex] = useState(resolveInitialIndex({ options, defaultValue }))
Expand All @@ -87,9 +86,9 @@ export function Autocomplete<TValue>({
const clamped = Math.min(focusIndex, filtered.length - 1)
const next = Math.max(0, clamped - 1)
setFocusIndex(next)
const focused = filtered[next]
if (onChange && focused !== undefined) {
onChange(focused.value)
const focusedOption = filtered[next]
if (onChange && focusedOption !== undefined) {
onChange(focusedOption.value)
}
return
}
Expand All @@ -100,17 +99,17 @@ export function Autocomplete<TValue>({
}
const next = Math.min(filtered.length - 1, focusIndex + 1)
setFocusIndex(next)
const focused = filtered[next]
if (onChange && focused !== undefined) {
onChange(focused.value)
const focusedOption = filtered[next]
if (onChange && focusedOption !== undefined) {
onChange(focusedOption.value)
}
return
}

if (key.return) {
const focused = filtered[focusIndex]
if (onSubmit && focused !== undefined && !focused.disabled) {
onSubmit(focused.value)
const focusedOption = filtered[focusIndex]
if (onSubmit && focusedOption !== undefined && !focusedOption.disabled) {
onSubmit(focusedOption.value)
}
return
}
Expand Down Expand Up @@ -151,15 +150,15 @@ export function Autocomplete<TValue>({
setFocusIndex(0)
}
},
{ isActive: !isDisabled }
{ isActive: focused && !disabled }
)

return (
<Box flexDirection="column">
<SearchInput
value={search}
placeholder={placeholder}
isDisabled={isDisabled}
disabled={disabled}
cursorOffset={cursorOffset}
/>
{match(filtered.length > 0)
Expand All @@ -179,7 +178,7 @@ export function Autocomplete<TValue>({
indicator={indicator}
isFocused={isFocused}
isSelected={isFocused}
isDisabled={isDisabled}
disabled={disabled}
/>
)
})}
Expand All @@ -203,7 +202,7 @@ export function Autocomplete<TValue>({
interface SearchInputProps {
readonly value: string
readonly placeholder?: string
readonly isDisabled: boolean
readonly disabled: boolean
readonly cursorOffset: number
}

Expand Down Expand Up @@ -232,7 +231,7 @@ function defaultFilter<TValue>(search: string, option: PromptOption<TValue>): bo
function SearchInput({
value,
placeholder,
isDisabled,
disabled,
cursorOffset,
}: SearchInputProps): ReactElement {
return (
Expand All @@ -243,7 +242,7 @@ function SearchInput({
{match({ isEmpty: value === '', hasPlaceholder: placeholder !== undefined })
.with({ isEmpty: true, hasPlaceholder: true }, () => <Text dimColor>{placeholder}</Text>)
.otherwise(() => (
<CursorValue value={value} cursor={cursorOffset} isDisabled={isDisabled} />
<CursorValue value={value} cursor={cursorOffset} disabled={disabled} />
))}
</Box>
)
Expand Down
Loading
Loading