From 4d96061cb443fd69298bc0598e78df9704279c7a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 23 Mar 2026 02:28:01 +0000 Subject: [PATCH] Split collapsible search input runtime owners --- .../subsystems/frontend-primitives.md | 8 ++ .../shared/CollapsibleSearchInput.tsx | 89 +++-------------- .../SharedPrimitives.guardrails.test.ts | 28 ++++++ .../shared/__tests__/SearchInput.test.tsx | 32 +++++++ .../shared/collapsibleSearchInputModel.ts | 25 +++++ .../shared/useCollapsibleSearchInputState.ts | 96 +++++++++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 17 ++++ 7 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 frontend-modern/src/components/shared/collapsibleSearchInputModel.ts create mode 100644 frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 657523844..46495b4c1 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 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 diff --git a/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx b/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx index 296583dc2..057a73cd4 100644 --- a/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx +++ b/frontend-modern/src/components/shared/CollapsibleSearchInput.tsx @@ -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 { - triggerLabel?: string; - fullWidthWhenExpanded?: boolean; -} +export type { CollapsibleSearchInputProps } from './collapsibleSearchInputModel'; export const CollapsibleSearchInput: Component = (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 (
(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} >
} @@ -107,7 +48,7 @@ export const CollapsibleSearchInput: Component = (p history={props.history} tips={props.tips} inputRef={(el) => { - inputRef = el; + collapsible.setInputRef(el); props.inputRef?.(el); }} onBeforeAutoFocus={props.onBeforeAutoFocus} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index da3b901e5..e2873d37a 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx index eb75a1387..002a38637 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 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(() => ); diff --git a/frontend-modern/src/components/shared/collapsibleSearchInputModel.ts b/frontend-modern/src/components/shared/collapsibleSearchInputModel.ts new file mode 100644 index 000000000..aea577dc7 --- /dev/null +++ b/frontend-modern/src/components/shared/collapsibleSearchInputModel.ts @@ -0,0 +1,25 @@ +import type { SearchInputProps } from './SearchInput'; + +export interface CollapsibleSearchInputProps extends Omit { + 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(); +}; diff --git a/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts b/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts new file mode 100644 index 000000000..c0e960f4b --- /dev/null +++ b/frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts @@ -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, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d23bd8cfc..eb211f435 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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 => {', );