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