Split search field runtime owners

This commit is contained in:
rcourtman
2026-03-23 02:24:47 +00:00
parent 70d34324fe
commit 81f3ef2bfe
7 changed files with 215 additions and 62 deletions
@@ -270,6 +270,14 @@ selection, and `frontend-modern/src/components/shared/commandPaletteModel.ts`
owns canonical command construction plus query normalization and filtering
policy. Future command-palette work should extend those owners instead of
pushing route construction or search policy back into the shared shell.
The shared search field now follows that same owner split.
`frontend-modern/src/components/shared/SearchField.tsx` stays the render shell,
`frontend-modern/src/components/shared/useSearchFieldState.ts` owns focused-
Escape clear/blur behavior and input-ref lifecycle, and
`frontend-modern/src/components/shared/searchFieldModel.ts` owns clear/shortcut
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 pulse data grid now follows that same owner split.
`frontend-modern/src/components/shared/PulseDataGrid.tsx` stays the render
shell, `frontend-modern/src/components/shared/usePulseDataGridState.ts` owns
@@ -1,76 +1,29 @@
import { Component, Show } from 'solid-js';
import { type SearchFieldProps } from './searchFieldModel';
import { useSearchFieldState } from './useSearchFieldState';
type SearchFieldKeyboardEvent = KeyboardEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
type SearchFieldFocusEvent = FocusEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
type SearchFieldMouseEvent = MouseEvent & {
currentTarget: HTMLButtonElement;
target: Element;
};
export interface SearchFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
title?: string;
inputRef?: (el: HTMLInputElement) => void;
class?: string;
inputClass?: string;
disabled?: boolean;
onKeyDown?: (event: SearchFieldKeyboardEvent) => void;
onBlur?: (event: SearchFieldFocusEvent) => void;
showClearButton?: boolean;
clearOnFocusedEscape?: boolean;
shortcutHint?: string;
hasTrailingControls?: boolean;
trailingControls?: import('solid-js').JSX.Element;
onClearMouseDown?: (event: SearchFieldMouseEvent) => void;
}
export type {
SearchFieldFocusEvent,
SearchFieldKeyboardEvent,
SearchFieldMouseEvent,
SearchFieldProps,
} from './searchFieldModel';
export const SearchField: Component<SearchFieldProps> = (props) => {
let inputEl: HTMLInputElement | undefined;
const showShortcutHint = () => Boolean(props.shortcutHint && !props.value);
const showClearButton = () =>
(props.showClearButton ?? true) && Boolean(props.value) && !props.disabled;
const inputPaddingRight = () => {
if (props.hasTrailingControls) return 'pr-14 sm:pr-20';
if (showShortcutHint()) return 'pr-20 sm:pr-24';
if (showClearButton()) return 'pr-8';
return 'pr-8';
};
const search = useSearchFieldState(props);
return (
<div class={`relative w-full ${props.class ?? ''}`}>
<input
ref={(el) => {
inputEl = el;
props.inputRef?.(el);
}}
ref={search.setInputRef}
type="text"
placeholder={props.placeholder ?? 'Search...'}
value={props.value}
disabled={props.disabled}
onInput={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && (props.clearOnFocusedEscape ?? true)) {
if (props.value) {
props.onChange('');
}
inputEl?.blur();
}
props.onKeyDown?.(e);
}}
onBlur={(e) => props.onBlur?.(e)}
class={`w-full pl-8 sm:pl-9 ${inputPaddingRight()} py-1.5 sm:py-2 text-sm border border-border rounded-md
onKeyDown={search.handleKeyDown}
onBlur={search.handleBlur}
class={`w-full pl-8 sm:pl-9 ${search.inputPaddingRight()} py-1.5 sm:py-2 text-sm border border-border rounded-md
bg-surface text-base-content placeholder-muted
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all disabled:opacity-60 disabled:cursor-not-allowed ${props.inputClass ?? ''}`}
title={props.title}
@@ -89,12 +42,12 @@ export const SearchField: Component<SearchFieldProps> = (props) => {
/>
</svg>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<Show when={showShortcutHint()}>
<Show when={search.showShortcutHint()}>
<span class="pointer-events-none hidden items-center rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[10px] font-semibold text-muted sm:inline-flex">
{props.shortcutHint}
</span>
</Show>
<Show when={showClearButton()}>
<Show when={search.showClearButton()}>
<button
type="button"
class="p-1 rounded-full bg-surface-hover text-muted hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900 dark:hover:text-red-400 transition-all duration-150 active:scale-90"
@@ -21,6 +21,8 @@ import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?ra
import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw';
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 interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
@@ -40,6 +42,7 @@ import infrastructureDetailsDrawerStateSource from '@/components/shared/useInfra
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
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 interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
@@ -409,4 +412,22 @@ describe('shared primitive guardrails', () => {
expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
});
it('keeps search field on shell, runtime, and model owners', () => {
expect(searchFieldSource).toContain('useSearchFieldState');
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
expect(searchFieldSource).not.toContain("if (props.hasTrailingControls) return 'pr-14 sm:pr-20'");
expect(searchFieldSource).not.toContain("if (e.key === 'Escape'");
expect(searchFieldStateSource).toContain('export function useSearchFieldState');
expect(searchFieldStateSource).toContain('let inputEl: HTMLInputElement');
expect(searchFieldStateSource).toContain("if (event.key === 'Escape'");
expect(searchFieldStateSource).toContain('inputEl?.blur()');
expect(searchFieldStateSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldShortcutHint');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
});
});
@@ -1,6 +1,9 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import { SearchField } from '@/components/shared/SearchField';
describe('SearchField', () => {
@@ -8,6 +11,24 @@ describe('SearchField', () => {
cleanup();
});
it('keeps search field on shell, runtime, and model owners', () => {
expect(searchFieldSource).toContain('useSearchFieldState');
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
expect(searchFieldSource).not.toContain("if (props.hasTrailingControls) return 'pr-14 sm:pr-20'");
expect(searchFieldSource).not.toContain("if (e.key === 'Escape'");
expect(searchFieldStateSource).toContain('export function useSearchFieldState');
expect(searchFieldStateSource).toContain('let inputEl: HTMLInputElement');
expect(searchFieldStateSource).toContain("if (event.key === 'Escape'");
expect(searchFieldStateSource).toContain('inputEl?.blur()');
expect(searchFieldStateSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldShortcutHint');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
});
it('renders the shortcut hint when empty', () => {
render(() => (
<SearchField value="" onChange={vi.fn()} placeholder="Search field" shortcutHint="Cmd+K" />
@@ -0,0 +1,55 @@
import type { JSX } from 'solid-js';
export type SearchFieldKeyboardEvent = KeyboardEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
export type SearchFieldFocusEvent = FocusEvent & {
currentTarget: HTMLInputElement;
target: Element;
};
export type SearchFieldMouseEvent = MouseEvent & {
currentTarget: HTMLButtonElement;
target: Element;
};
export interface SearchFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
title?: string;
inputRef?: (el: HTMLInputElement) => void;
class?: string;
inputClass?: string;
disabled?: boolean;
onKeyDown?: (event: SearchFieldKeyboardEvent) => void;
onBlur?: (event: SearchFieldFocusEvent) => void;
showClearButton?: boolean;
clearOnFocusedEscape?: boolean;
shortcutHint?: string;
hasTrailingControls?: boolean;
trailingControls?: JSX.Element;
onClearMouseDown?: (event: SearchFieldMouseEvent) => void;
}
export const shouldShowSearchFieldShortcutHint = (value: string, shortcutHint?: string) =>
Boolean(shortcutHint && !value);
export const shouldShowSearchFieldClearButton = (
value: string,
disabled?: boolean,
showClearButton?: boolean,
) => (showClearButton ?? true) && Boolean(value) && !disabled;
export const getSearchFieldInputPaddingRightClass = (options: {
hasTrailingControls?: boolean;
showShortcutHint: boolean;
showClearButton: boolean;
}) => {
if (options.hasTrailingControls) return 'pr-14 sm:pr-20';
if (options.showShortcutHint) return 'pr-20 sm:pr-24';
if (options.showClearButton) return 'pr-8';
return 'pr-8';
};
@@ -0,0 +1,82 @@
import {
getSearchFieldInputPaddingRightClass,
shouldShowSearchFieldClearButton,
shouldShowSearchFieldShortcutHint,
type SearchFieldFocusEvent,
type SearchFieldKeyboardEvent,
type SearchFieldProps,
} from './searchFieldModel';
type SearchFieldStateOptions = Pick<
SearchFieldProps,
| 'clearOnFocusedEscape'
| 'disabled'
| 'hasTrailingControls'
| 'inputRef'
| 'onBlur'
| 'onChange'
| 'onKeyDown'
| 'showClearButton'
| 'shortcutHint'
| 'value'
>;
export function useSearchFieldState(options: SearchFieldStateOptions) {
let inputEl: HTMLInputElement | undefined;
const normalizeEventTarget = <
TEvent extends { currentTarget: EventTarget | null; target: EventTarget | null },
>(
event: TEvent,
) => {
const currentTarget = event.currentTarget as HTMLInputElement;
const normalizedTarget = event.target as Element;
return new Proxy(event, {
get(eventTarget, prop, receiver) {
if (prop === 'currentTarget') return currentTarget;
if (prop === 'target') return normalizedTarget;
return Reflect.get(eventTarget, prop, receiver);
},
});
};
const showShortcutHint = () =>
shouldShowSearchFieldShortcutHint(options.value, options.shortcutHint);
const showClearButton = () =>
shouldShowSearchFieldClearButton(options.value, options.disabled, options.showClearButton);
const inputPaddingRight = () =>
getSearchFieldInputPaddingRightClass({
hasTrailingControls: options.hasTrailingControls,
showShortcutHint: showShortcutHint(),
showClearButton: showClearButton(),
});
const setInputRef = (el: HTMLInputElement) => {
inputEl = el;
options.inputRef?.(el);
};
const handleKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (event.key === 'Escape' && (options.clearOnFocusedEscape ?? true)) {
if (options.value) {
options.onChange('');
}
inputEl?.blur();
}
options.onKeyDown?.(normalizeEventTarget(event) as SearchFieldKeyboardEvent);
};
const handleBlur = (event: FocusEvent & { currentTarget: HTMLInputElement }) => {
options.onBlur?.(normalizeEventTarget(event) as SearchFieldFocusEvent);
};
return {
handleBlur,
handleKeyDown,
inputPaddingRight,
setInputRef,
showClearButton,
showShortcutHint,
};
}
@@ -26,6 +26,8 @@ import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?ra
import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw';
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 infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
@@ -39,6 +41,7 @@ import historyChartStateSource from '@/components/shared/useHistoryChartState.ts
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
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 interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
@@ -2655,6 +2658,16 @@ describe('frontend resource type boundaries', () => {
expect(pulseDataGridModelSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridModelSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
expect(searchFieldSource).toContain('useSearchFieldState');
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
expect(searchFieldSource).not.toContain("if (props.hasTrailingControls) return 'pr-14 sm:pr-20'");
expect(searchFieldSource).not.toContain("if (e.key === 'Escape'");
expect(searchFieldStateSource).toContain('let inputEl: HTMLInputElement');
expect(searchFieldStateSource).toContain("if (event.key === 'Escape'");
expect(searchFieldStateSource).toContain('inputEl?.blur()');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldShortcutHint');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(infrastructureSummaryModelSource).not.toContain(
'const asTrimmedString = (value: unknown): string | null => {',
);