Split search input runtime owners

This commit is contained in:
rcourtman 2026-03-23 02:33:31 +00:00
parent 4d96061cb4
commit bc235e02b2
7 changed files with 192 additions and 74 deletions

View file

@ -278,6 +278,14 @@ Escape clear/blur behavior and input-ref lifecycle, and
visibility rules plus trailing-control padding policy. Future search-field work
should extend those owners instead of pushing event behavior or layout policy
back into the shared shell.
The shared search input now follows that same owner split.
`frontend-modern/src/components/shared/SearchInput.tsx` stays the render shell,
`frontend-modern/src/components/shared/useSearchInputState.ts` owns input-ref
lifecycle, type-to-search registration, and enhancement runtime composition,
and `frontend-modern/src/components/shared/searchInputModel.ts` owns the shared
search-input contract plus shortcut-hint and trailing-control policy. Future
search-input work should extend those owners instead of pushing type-to-search
or enhancement wiring back into the shared shell.
The shared collapsible search input now follows that same owner split.
`frontend-modern/src/components/shared/CollapsibleSearchInput.tsx` stays the
render shell, `frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts`

View file

@ -1,75 +1,16 @@
import { Component } from 'solid-js';
type SearchInputKeyboardEvent = KeyboardEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
import { SearchField } from '@/components/shared/SearchField';
import {
SearchInputHistoryDropdown,
SearchInputTrailingControls,
} from '@/components/shared/SearchInputEnhancements';
import { useTypeToSearch } from '@/hooks/useTypeToSearch';
import {
type SearchHistoryConfig,
type SearchTipsConfig,
useSearchInputEnhancements,
} from '@/components/shared/useSearchInputEnhancements';
import { type SearchInputProps } from './searchInputModel';
import { useSearchInputState } from './useSearchInputState';
export interface SearchInputProps {
value: () => string;
onChange: (value: string) => void;
placeholder?: string;
title?: string;
history?: SearchHistoryConfig;
tips?: SearchTipsConfig;
inputRef?: (el: HTMLInputElement) => void;
class?: string;
inputClass?: string;
disabled?: boolean;
onKeyDown?: (event: SearchInputKeyboardEvent) => void;
/** When false, disables the default type-to-search behavior for this search input. */
typeToSearch?: boolean;
/** When true, pressing Escape clears the search even if focus is elsewhere on the page. */
clearOnEscape?: boolean;
/** When false, pressing Escape while focused leaves the value unchanged. */
clearOnFocusedEscape?: boolean;
/** When true, Ctrl/Cmd+F focuses this search input via the shared search handler. */
focusOnShortcut?: boolean;
/** When true, Backspace outside editable fields focuses the input and deletes a character. */
captureBackspace?: boolean;
/** Optional trailing hint shown while the search is empty. */
shortcutHint?: string;
/** Called before auto-focus — return true to prevent focus (e.g. when AI chat should capture input instead). */
onBeforeAutoFocus?: () => boolean;
}
export type { SearchInputKeyboardEvent, SearchInputProps } from './searchInputModel';
export const SearchInput: Component<SearchInputProps> = (props) => {
let searchInputEl: HTMLInputElement | undefined;
useTypeToSearch({
getInput: () => searchInputEl,
enabled: () => props.typeToSearch ?? true,
onBeforeFocus: props.onBeforeAutoFocus,
clearOnEscape: () => props.clearOnEscape ?? false,
getValue: props.value,
onClear: () => props.onChange(''),
focusOnShortcut: () => props.focusOnShortcut ?? false,
captureBackspace: () => props.captureBackspace ?? false,
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
const enhancements = useSearchInputEnhancements({
history: props.history,
tips: props.tips,
value: props.value,
onChange: props.onChange,
onFieldKeyDown: props.onKeyDown,
focusInput: focusSearchInput,
});
const search = useSearchInputState(props);
return (
<div class={`relative w-full ${props.class ?? ''}`}>
@ -78,21 +19,20 @@ export const SearchInput: Component<SearchInputProps> = (props) => {
onChange={props.onChange}
placeholder={props.placeholder}
title={props.title}
inputRef={(el) => {
searchInputEl = el;
props.inputRef?.(el);
}}
inputRef={search.setInputRef}
inputClass={props.inputClass}
disabled={props.disabled}
clearOnFocusedEscape={props.clearOnFocusedEscape}
shortcutHint={enhancements.isSimple() ? props.shortcutHint : undefined}
hasTrailingControls={!enhancements.isSimple()}
onClearMouseDown={enhancements.onClearMouseDown}
onKeyDown={enhancements.onFieldKeyDown}
onBlur={enhancements.onFieldBlur}
trailingControls={<SearchInputTrailingControls state={enhancements} tips={props.tips} />}
shortcutHint={search.shortcutHint()}
hasTrailingControls={search.showTrailingControls()}
onClearMouseDown={search.enhancements.onClearMouseDown}
onKeyDown={search.enhancements.onFieldKeyDown}
onBlur={search.enhancements.onFieldBlur}
trailingControls={
<SearchInputTrailingControls state={search.enhancements} tips={props.tips} />
}
/>
<SearchInputHistoryDropdown state={enhancements} />
<SearchInputHistoryDropdown state={search.enhancements} />
</div>
);
};

View file

@ -25,6 +25,8 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
@ -46,6 +48,7 @@ import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
@ -434,6 +437,27 @@ describe('shared primitive guardrails', () => {
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
});
it('keeps search input on shell, runtime, and model owners', () => {
expect(searchInputSource).toContain('useSearchInputState');
expect(searchInputSource).not.toContain('let searchInputEl: HTMLInputElement');
expect(searchInputSource).not.toContain('useTypeToSearch');
expect(searchInputSource).not.toContain('useSearchInputEnhancements');
expect(searchInputSource).not.toContain(
'enhancements.isSimple() ? props.shortcutHint : undefined',
);
expect(searchInputStateSource).toContain('export function useSearchInputState');
expect(searchInputStateSource).toContain('let searchInputEl: HTMLInputElement');
expect(searchInputStateSource).toContain('useTypeToSearch');
expect(searchInputStateSource).toContain('useSearchInputEnhancements');
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputStateSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('export interface SearchInputProps');
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');

View file

@ -3,6 +3,9 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createSignal } from 'solid-js';
import { CollapsibleSearchInput } from '@/components/shared/CollapsibleSearchInput';
import { SearchInput } from '@/components/shared/SearchInput';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
@ -38,6 +41,25 @@ describe('SearchInput', () => {
cleanup();
});
it('keeps search input on shell, runtime, and model owners', () => {
expect(searchInputSource).toContain('useSearchInputState');
expect(searchInputSource).not.toContain('let searchInputEl: HTMLInputElement');
expect(searchInputSource).not.toContain('useTypeToSearch');
expect(searchInputSource).not.toContain('useSearchInputEnhancements');
expect(searchInputSource).not.toContain('enhancements.isSimple() ? props.shortcutHint : undefined');
expect(searchInputStateSource).toContain('export function useSearchInputState');
expect(searchInputStateSource).toContain('let searchInputEl: HTMLInputElement');
expect(searchInputStateSource).toContain('useTypeToSearch');
expect(searchInputStateSource).toContain('useSearchInputEnhancements');
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputStateSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('export interface SearchInputProps');
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');

View file

@ -0,0 +1,35 @@
import type {
SearchHistoryConfig,
SearchTipsConfig,
} from '@/components/shared/useSearchInputEnhancements';
export type SearchInputKeyboardEvent = KeyboardEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
export interface SearchInputProps {
value: () => string;
onChange: (value: string) => void;
placeholder?: string;
title?: string;
history?: SearchHistoryConfig;
tips?: SearchTipsConfig;
inputRef?: (el: HTMLInputElement) => void;
class?: string;
inputClass?: string;
disabled?: boolean;
onKeyDown?: (event: SearchInputKeyboardEvent) => void;
typeToSearch?: boolean;
clearOnEscape?: boolean;
clearOnFocusedEscape?: boolean;
focusOnShortcut?: boolean;
captureBackspace?: boolean;
shortcutHint?: string;
onBeforeAutoFocus?: () => boolean;
}
export const getSearchInputShortcutHint = (isSimple: boolean, shortcutHint?: string) =>
isSimple ? shortcutHint : undefined;
export const shouldSearchInputShowTrailingControls = (isSimple: boolean) => !isSimple;

View file

@ -0,0 +1,76 @@
import { useTypeToSearch } from '@/hooks/useTypeToSearch';
import {
useSearchInputEnhancements,
type SearchInputEnhancementsState,
} from '@/components/shared/useSearchInputEnhancements';
import {
getSearchInputShortcutHint,
shouldSearchInputShowTrailingControls,
type SearchInputProps,
} from './searchInputModel';
type SearchInputStateOptions = Pick<
SearchInputProps,
| 'captureBackspace'
| 'clearOnEscape'
| 'history'
| 'focusOnShortcut'
| 'inputRef'
| 'onBeforeAutoFocus'
| 'onChange'
| 'onKeyDown'
| 'shortcutHint'
| 'tips'
| 'typeToSearch'
| 'value'
>;
export function useSearchInputState(options: SearchInputStateOptions): {
enhancements: SearchInputEnhancementsState;
setInputRef: (el: HTMLInputElement) => void;
shortcutHint: () => string | undefined;
showTrailingControls: () => boolean;
} {
let searchInputEl: HTMLInputElement | undefined;
useTypeToSearch({
getInput: () => searchInputEl,
enabled: () => options.typeToSearch ?? true,
onBeforeFocus: options.onBeforeAutoFocus,
clearOnEscape: () => options.clearOnEscape ?? false,
getValue: options.value,
onClear: () => options.onChange(''),
focusOnShortcut: () => options.focusOnShortcut ?? false,
captureBackspace: () => options.captureBackspace ?? false,
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
const enhancements = useSearchInputEnhancements({
history: options.history,
tips: options.tips,
value: options.value,
onChange: options.onChange,
onFieldKeyDown: options.onKeyDown,
focusInput: focusSearchInput,
});
const setInputRef = (el: HTMLInputElement) => {
searchInputEl = el;
options.inputRef?.(el);
};
const shortcutHint = () =>
getSearchInputShortcutHint(enhancements.isSimple(), options.shortcutHint);
const showTrailingControls = () =>
shouldSearchInputShowTrailingControls(enhancements.isSimple());
return {
enhancements,
setInputRef,
shortcutHint,
showTrailingControls,
};
}

View file

@ -30,6 +30,8 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
@ -45,6 +47,7 @@ import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
@ -2671,6 +2674,16 @@ describe('frontend resource type boundaries', () => {
expect(searchFieldModelSource).toContain('shouldShowSearchFieldShortcutHint');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchInputSource).toContain('useSearchInputState');
expect(searchInputSource).not.toContain('let searchInputEl: HTMLInputElement');
expect(searchInputSource).not.toContain('useTypeToSearch');
expect(searchInputSource).not.toContain('useSearchInputEnhancements');
expect(searchInputStateSource).toContain('let searchInputEl: HTMLInputElement');
expect(searchInputStateSource).toContain('useTypeToSearch');
expect(searchInputStateSource).toContain('useSearchInputEnhancements');
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');
expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch');