mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-13 23:54:03 +00:00
Split search input runtime owners
This commit is contained in:
parent
4d96061cb4
commit
bc235e02b2
7 changed files with 192 additions and 74 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
35
frontend-modern/src/components/shared/searchInputModel.ts
Normal file
35
frontend-modern/src/components/shared/searchInputModel.ts
Normal 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;
|
||||
76
frontend-modern/src/components/shared/useSearchInputState.ts
Normal file
76
frontend-modern/src/components/shared/useSearchInputState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue