mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 12:52:25 +00:00
Split search field runtime owners
This commit is contained in:
@@ -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 => {',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user