From bc235e02b281a67152313091ce3e645de8a59491 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 23 Mar 2026 02:33:31 +0000 Subject: [PATCH] Split search input runtime owners --- .../subsystems/frontend-primitives.md | 8 ++ .../src/components/shared/SearchInput.tsx | 88 +++---------------- .../SharedPrimitives.guardrails.test.ts | 24 +++++ .../shared/__tests__/SearchInput.test.tsx | 22 +++++ .../src/components/shared/searchInputModel.ts | 35 ++++++++ .../components/shared/useSearchInputState.ts | 76 ++++++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 13 +++ 7 files changed, 192 insertions(+), 74 deletions(-) create mode 100644 frontend-modern/src/components/shared/searchInputModel.ts create mode 100644 frontend-modern/src/components/shared/useSearchInputState.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 46495b4c1..5bf008424 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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` diff --git a/frontend-modern/src/components/shared/SearchInput.tsx b/frontend-modern/src/components/shared/SearchInput.tsx index c5cfe6661..25e06b0af 100644 --- a/frontend-modern/src/components/shared/SearchInput.tsx +++ b/frontend-modern/src/components/shared/SearchInput.tsx @@ -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 = (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 (
@@ -78,21 +19,20 @@ export const SearchInput: Component = (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={} + shortcutHint={search.shortcutHint()} + hasTrailingControls={search.showTrailingControls()} + onClearMouseDown={search.enhancements.onClearMouseDown} + onKeyDown={search.enhancements.onFieldKeyDown} + onBlur={search.enhancements.onFieldBlur} + trailingControls={ + + } /> - +
); }; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index e2873d37a..c9d0af624 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); diff --git a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx index 002a38637..4ebe7041a 100644 --- a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/shared/searchInputModel.ts b/frontend-modern/src/components/shared/searchInputModel.ts new file mode 100644 index 000000000..41b652a14 --- /dev/null +++ b/frontend-modern/src/components/shared/searchInputModel.ts @@ -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; diff --git a/frontend-modern/src/components/shared/useSearchInputState.ts b/frontend-modern/src/components/shared/useSearchInputState.ts new file mode 100644 index 000000000..8bb504d0a --- /dev/null +++ b/frontend-modern/src/components/shared/useSearchInputState.ts @@ -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, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index eb211f435..94b593a9e 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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');