Split collapsible search input runtime owners

This commit is contained in:
rcourtman
2026-03-23 02:28:01 +00:00
parent 81f3ef2bfe
commit 4d96061cb4
7 changed files with 221 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 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`
owns expand/collapse state, focus choreography, and type-to-search handoff, and
`frontend-modern/src/components/shared/collapsibleSearchInputModel.ts` owns
trigger-label, expanded-visibility, and full-width layout policy. Future
collapsible-search work should extend those owners instead of pushing
expand/collapse runtime or layout rules 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,87 +1,28 @@
import { Component, Show, createEffect, createSignal } from 'solid-js';
import { useTypeToSearch } from '@/hooks/useTypeToSearch';
import { SearchInput, type SearchInputProps } from '@/components/shared/SearchInput';
import { Component, Show } from 'solid-js';
import { SearchInput } from '@/components/shared/SearchInput';
import {
type CollapsibleSearchInputProps,
} from './collapsibleSearchInputModel';
import { useCollapsibleSearchInputState } from './useCollapsibleSearchInputState';
interface CollapsibleSearchInputProps extends Omit<SearchInputProps, 'typeToSearch'> {
triggerLabel?: string;
fullWidthWhenExpanded?: boolean;
}
export type { CollapsibleSearchInputProps } from './collapsibleSearchInputModel';
export const CollapsibleSearchInput: Component<CollapsibleSearchInputProps> = (props) => {
const [isExpanded, setIsExpanded] = createSignal(props.value().trim().length > 0);
let rootRef: HTMLDivElement | undefined;
let inputRef: HTMLInputElement | undefined;
let suppressCollapse = false;
const focusInput = (selectText = false) => {
queueMicrotask(() => {
if (!inputRef) return;
inputRef.focus();
if (selectText) {
inputRef.select?.();
}
});
};
const expandSearch = (selectText = false) => {
suppressCollapse = true;
queueMicrotask(() => {
suppressCollapse = false;
});
if (!isExpanded()) {
setIsExpanded(true);
}
focusInput(selectText);
};
const collapseIfEmpty = () => {
if (props.value().trim().length > 0) return;
setIsExpanded(false);
};
createEffect(() => {
if (props.value().trim().length > 0 && !isExpanded()) {
setIsExpanded(true);
}
});
useTypeToSearch({
getInput: () => inputRef,
prepareInput: () => {
if (!isExpanded()) {
setIsExpanded(true);
}
},
onBeforeFocus: props.onBeforeAutoFocus,
});
const triggerLabel = () => props.triggerLabel ?? 'Search';
const showExpanded = () => isExpanded() || props.value().trim().length > 0;
const rootClass = () => {
const baseClass = props.class ?? '';
if (!props.fullWidthWhenExpanded) return baseClass;
const layoutClass = showExpanded() ? 'order-last basis-full w-full' : 'shrink-0 md:ml-auto';
return `${baseClass} ${layoutClass}`.trim();
};
const collapsible = useCollapsibleSearchInputState(props);
return (
<div
ref={(el) => (rootRef = el)}
class={rootClass()}
onFocusOut={(e) => {
if (suppressCollapse) return;
const next = e.relatedTarget as Node | null;
if (next && rootRef?.contains(next)) return;
collapseIfEmpty();
}}
ref={collapsible.setRootRef}
class={collapsible.rootClass()}
onFocusOut={collapsible.handleFocusOut}
>
<Show
when={showExpanded()}
when={collapsible.showExpanded()}
fallback={
<div class="inline-flex rounded-md bg-surface-hover p-0.5">
<button
type="button"
onClick={() => expandSearch(false)}
onClick={() => collapsible.expandSearch(false)}
class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 text-muted hover:text-base-content hover:bg-surface-hover"
aria-label={props.title ?? props.placeholder ?? 'Open search'}
title={`${props.placeholder ?? 'Search'} (/)`}
@@ -94,7 +35,7 @@ export const CollapsibleSearchInput: Component<CollapsibleSearchInputProps> = (p
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span>{triggerLabel()}</span>
<span>{collapsible.triggerLabel()}</span>
</button>
</div>
}
@@ -107,7 +48,7 @@ export const CollapsibleSearchInput: Component<CollapsibleSearchInputProps> = (p
history={props.history}
tips={props.tips}
inputRef={(el) => {
inputRef = el;
collapsible.setInputRef(el);
props.inputRef?.(el);
}}
onBeforeAutoFocus={props.onBeforeAutoFocus}
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import calloutCardSource from '@/components/shared/CalloutCard.tsx?raw';
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw';
import containerUpdateBadgeModelSource from '@/components/shared/containerUpdateBadgeModel.ts?raw';
import densityMapSource from '@/components/shared/DensityMap.tsx?raw';
@@ -34,6 +36,7 @@ import monitoredSystemLimitWarningBannerSource from '@/components/shared/Monitor
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
import densityMapStateSource from '@/components/shared/useDensityMapState.ts?raw';
import helpIconStateSource from '@/components/shared/useHelpIconState.ts?raw';
@@ -430,4 +433,29 @@ describe('shared primitive guardrails', () => {
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');
expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch');
expect(collapsibleSearchInputSource).not.toContain(
"const triggerLabel = () => props.triggerLabel ?? 'Search'",
);
expect(collapsibleSearchInputSource).not.toContain(
"const layoutClass = showExpanded() ? 'order-last basis-full w-full' : 'shrink-0 md:ml-auto'",
);
expect(collapsibleSearchInputStateSource).toContain(
'export function useCollapsibleSearchInputState',
);
expect(collapsibleSearchInputStateSource).toContain('createSignal');
expect(collapsibleSearchInputStateSource).toContain('useTypeToSearch');
expect(collapsibleSearchInputStateSource).toContain('queueMicrotask');
expect(collapsibleSearchInputStateSource).toContain('setIsExpanded(true)');
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchTriggerLabel');
expect(collapsibleSearchInputModelSource).toContain('shouldShowCollapsibleSearchExpanded');
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchRootClass');
expect(collapsibleSearchInputModelSource).toContain('order-last basis-full w-full');
});
});
@@ -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 collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch';
const SearchHarness = (props: {
@@ -35,6 +38,35 @@ describe('SearchInput', () => {
cleanup();
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');
expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch');
expect(collapsibleSearchInputSource).not.toContain("const triggerLabel = () => props.triggerLabel ?? 'Search'");
expect(collapsibleSearchInputSource).not.toContain(
"const layoutClass = showExpanded() ? 'order-last basis-full w-full' : 'shrink-0 md:ml-auto'",
);
expect(collapsibleSearchInputStateSource).toContain(
'export function useCollapsibleSearchInputState',
);
expect(collapsibleSearchInputStateSource).toContain('createSignal');
expect(collapsibleSearchInputStateSource).toContain('useTypeToSearch');
expect(collapsibleSearchInputStateSource).toContain('queueMicrotask');
expect(collapsibleSearchInputStateSource).toContain('setIsExpanded(true)');
expect(collapsibleSearchInputModelSource).toContain(
'getCollapsibleSearchTriggerLabel',
);
expect(collapsibleSearchInputModelSource).toContain(
'shouldShowCollapsibleSearchExpanded',
);
expect(collapsibleSearchInputModelSource).toContain(
'getCollapsibleSearchRootClass',
);
expect(collapsibleSearchInputModelSource).toContain('order-last basis-full w-full');
});
it('captures typed characters by default when focus is outside the input', async () => {
render(() => <SearchHarness />);
@@ -0,0 +1,25 @@
import type { SearchInputProps } from './SearchInput';
export interface CollapsibleSearchInputProps extends Omit<SearchInputProps, 'typeToSearch'> {
triggerLabel?: string;
fullWidthWhenExpanded?: boolean;
}
export const getCollapsibleSearchTriggerLabel = (triggerLabel?: string) =>
triggerLabel ?? 'Search';
export const shouldShowCollapsibleSearchExpanded = (isExpanded: boolean, value: string) =>
isExpanded || value.trim().length > 0;
export const getCollapsibleSearchRootClass = (options: {
className?: string;
fullWidthWhenExpanded?: boolean;
showExpanded: boolean;
}) => {
const baseClass = options.className ?? '';
if (!options.fullWidthWhenExpanded) return baseClass;
const layoutClass = options.showExpanded
? 'order-last basis-full w-full'
: 'shrink-0 md:ml-auto';
return `${baseClass} ${layoutClass}`.trim();
};
@@ -0,0 +1,96 @@
import { createEffect, createSignal } from 'solid-js';
import { useTypeToSearch } from '@/hooks/useTypeToSearch';
import {
getCollapsibleSearchRootClass,
getCollapsibleSearchTriggerLabel,
shouldShowCollapsibleSearchExpanded,
type CollapsibleSearchInputProps,
} from './collapsibleSearchInputModel';
type CollapsibleSearchInputStateOptions = Pick<
CollapsibleSearchInputProps,
'class' | 'fullWidthWhenExpanded' | 'onBeforeAutoFocus' | 'triggerLabel' | 'value'
>;
export function useCollapsibleSearchInputState(options: CollapsibleSearchInputStateOptions) {
const [isExpanded, setIsExpanded] = createSignal(options.value().trim().length > 0);
let rootRef: HTMLDivElement | undefined;
let inputRef: HTMLInputElement | undefined;
let suppressCollapse = false;
const focusInput = (selectText = false) => {
queueMicrotask(() => {
if (!inputRef) return;
inputRef.focus();
if (selectText) {
inputRef.select?.();
}
});
};
const expandSearch = (selectText = false) => {
suppressCollapse = true;
queueMicrotask(() => {
suppressCollapse = false;
});
if (!isExpanded()) {
setIsExpanded(true);
}
focusInput(selectText);
};
const collapseIfEmpty = () => {
if (options.value().trim().length > 0) return;
setIsExpanded(false);
};
createEffect(() => {
if (options.value().trim().length > 0 && !isExpanded()) {
setIsExpanded(true);
}
});
useTypeToSearch({
getInput: () => inputRef,
prepareInput: () => {
if (!isExpanded()) {
setIsExpanded(true);
}
},
onBeforeFocus: options.onBeforeAutoFocus,
});
const showExpanded = () => shouldShowCollapsibleSearchExpanded(isExpanded(), options.value());
const triggerLabel = () => getCollapsibleSearchTriggerLabel(options.triggerLabel);
const rootClass = () =>
getCollapsibleSearchRootClass({
className: options.class,
fullWidthWhenExpanded: options.fullWidthWhenExpanded,
showExpanded: showExpanded(),
});
const setRootRef = (element: HTMLDivElement) => {
rootRef = element;
};
const setInputRef = (element: HTMLInputElement) => {
inputRef = element;
};
const handleFocusOut = (event: FocusEvent & { relatedTarget: EventTarget | null }) => {
if (suppressCollapse) return;
const next = event.relatedTarget as Node | null;
if (next && rootRef?.contains(next)) return;
collapseIfEmpty();
};
return {
expandSearch,
handleFocusOut,
rootClass,
setInputRef,
setRootRef,
showExpanded,
triggerLabel,
};
}
@@ -10,6 +10,8 @@ import alertTargetTypesSource from '@/utils/alertTargetTypes.ts?raw';
import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw';
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw';
import containerUpdateBadgeModelSource from '@/components/shared/containerUpdateBadgeModel.ts?raw';
import densityMapSource from '@/components/shared/DensityMap.tsx?raw';
@@ -35,6 +37,7 @@ import interactiveSparklineModelSource from '@/components/shared/interactiveSpar
import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw';
import sharedInfrastructureSummaryTableModelSource from '@/components/shared/infrastructureSummaryTableModel.ts?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
import helpIconStateSource from '@/components/shared/useHelpIconState.ts?raw';
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
@@ -2668,6 +2671,20 @@ describe('frontend resource type boundaries', () => {
expect(searchFieldModelSource).toContain('shouldShowSearchFieldShortcutHint');
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');
expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch');
expect(collapsibleSearchInputSource).not.toContain(
"const triggerLabel = () => props.triggerLabel ?? 'Search'",
);
expect(collapsibleSearchInputStateSource).toContain('createSignal');
expect(collapsibleSearchInputStateSource).toContain('useTypeToSearch');
expect(collapsibleSearchInputStateSource).toContain('queueMicrotask');
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchTriggerLabel');
expect(collapsibleSearchInputModelSource).toContain(
'shouldShowCollapsibleSearchExpanded',
);
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchRootClass');
expect(infrastructureSummaryModelSource).not.toContain(
'const asTrimmedString = (value: unknown): string | null => {',
);