Extract search input enhancement presentation owner

This commit is contained in:
rcourtman
2026-03-23 09:47:10 +00:00
parent 30ac658d71
commit 3f0ea4ec82
6 changed files with 122 additions and 16 deletions
@@ -367,6 +367,15 @@ and `frontend-modern/src/components/shared/searchInputModel.ts` owns the shared
search-input contract plus shortcut-hint and trailing-control policy. Future
search-input work should extend those owners instead of pushing type-to-search
or enhancement wiring back into the shared shell.
The search-input enhancement surfaces now follow that same owner split.
`frontend-modern/src/components/shared/SearchInputEnhancements.tsx` stays the
render shell, `frontend-modern/src/components/shared/useSearchInputEnhancements.ts`
owns search-history persistence, menu-open lifecycle, blur commit policy, and
tips/history interaction runtime, and
`frontend-modern/src/components/shared/searchInputEnhancementsModel.ts` owns
history-toggle copy plus history-menu button and row class policy. Future
search-input-enhancement work should extend those owners instead of pushing
history copy or menu presentation policy back into the shell.
The shared search tips popover now follows that same owner split.
`frontend-modern/src/components/shared/SearchTipsPopover.tsx` stays the render
shell, `frontend-modern/src/components/shared/useSearchTipsPopoverState.ts`
@@ -1,5 +1,16 @@
import { Component, For, Show } from 'solid-js';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import {
SEARCH_HISTORY_CLEAR_LABEL,
SEARCH_HISTORY_EMPTY_STATE_CLASS,
SEARCH_HISTORY_ENTRY_BUTTON_CLASS,
SEARCH_HISTORY_MENU_CLASS,
SEARCH_HISTORY_ROW_CLASS,
getSearchHistoryClearButtonClass,
getSearchHistoryDeleteButtonClass,
getSearchHistoryToggleButtonClass,
getSearchHistoryToggleTitle,
} from '@/components/shared/searchInputEnhancementsModel';
import type {
SearchInputEnhancementsState,
SearchTipsConfig,
@@ -16,19 +27,12 @@ export const SearchInputTrailingControls: Component<SearchInputTrailingControlsP
<button
ref={props.state.setHistoryToggleRef}
type="button"
class={`flex h-7 w-7 items-center justify-center rounded-md transition-colors
${
props.state.isHistoryOpen()
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`}
class={getSearchHistoryToggleButtonClass(props.state.isHistoryOpen())}
onClick={props.state.toggleHistory}
onMouseDown={props.state.onClearMouseDown}
aria-haspopup="listbox"
aria-expanded={props.state.isHistoryOpen()}
title={
props.state.searchHistory().length > 0 ? 'Show recent searches' : 'No recent searches yet'
}
title={getSearchHistoryToggleTitle(props.state.searchHistory().length)}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
@@ -59,22 +63,22 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
<Show when={props.state.hasHistory() && props.state.isHistoryOpen()}>
<div
ref={props.state.setHistoryMenuRef}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-md border border-border bg-surface text-sm shadow-sm"
class={SEARCH_HISTORY_MENU_CLASS}
role="listbox"
>
<Show
when={props.state.searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-muted">{props.state.emptyHistoryMessage()}</div>
<div class={SEARCH_HISTORY_EMPTY_STATE_CLASS}>{props.state.emptyHistoryMessage()}</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={props.state.searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900">
<div class={SEARCH_HISTORY_ROW_CLASS}>
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-base-content transition-colors hover:text-blue-600 focus:outline-none dark:hover:text-blue-300"
class={SEARCH_HISTORY_ENTRY_BUTTON_CLASS}
onClick={() => props.state.selectHistoryEntry(entry)}
onMouseDown={props.state.onClearMouseDown}
>
@@ -82,7 +86,7 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none"
class={getSearchHistoryDeleteButtonClass()}
title="Remove from history"
onClick={() => props.state.deleteHistoryEntry(entry)}
onMouseDown={props.state.onClearMouseDown}
@@ -102,7 +106,7 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-border px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
class={getSearchHistoryClearButtonClass()}
onClick={props.state.clearHistory}
onMouseDown={props.state.onClearMouseDown}
>
@@ -114,7 +118,7 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
{SEARCH_HISTORY_CLEAR_LABEL}
</button>
</Show>
</div>
@@ -37,6 +37,8 @@ import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw';
import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw';
import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw';
@@ -80,6 +82,7 @@ import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.
import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw';
import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw';
import toggleStateSource from '@/components/shared/useToggleState.ts?raw';
@@ -739,6 +742,25 @@ describe('shared primitive guardrails', () => {
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('export interface SearchInputProps');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsSource).not.toContain('No recent searches yet');
expect(searchInputEnhancementsSource).not.toContain('Clear history');
expect(searchInputEnhancementsSource).not.toContain('hover:bg-blue-50');
expect(searchInputEnhancementsStateSource).toContain('createSearchHistoryManager');
expect(searchInputEnhancementsStateSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsStateSource).toContain(
"options.history?.emptyMessage ?? 'Searches you run will appear here.'",
);
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS');
});
it('keeps search tips popover on shell, runtime, and model owners', () => {
@@ -5,6 +5,9 @@ import { CollapsibleSearchInput } from '@/components/shared/CollapsibleSearchInp
import { SearchInput } from '@/components/shared/SearchInput';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw';
import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw';
import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
@@ -58,6 +61,27 @@ describe('SearchInput', () => {
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputModelSource).toContain('export interface SearchInputProps');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsSource).not.toContain('No recent searches yet');
expect(searchInputEnhancementsSource).not.toContain('Clear history');
expect(searchInputEnhancementsSource).not.toContain('hover:bg-blue-50');
expect(searchInputStateSource).toContain('useSearchInputEnhancements');
expect(searchInputEnhancementsStateSource).toContain('createSearchHistoryManager');
expect(searchInputEnhancementsStateSource).toContain(
"options.history?.emptyMessage ?? 'Searches you run will appear here.'",
);
expect(searchInputEnhancementsStateSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS');
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
@@ -0,0 +1,28 @@
export const SEARCH_HISTORY_MENU_CLASS =
'absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-md border border-border bg-surface text-sm shadow-sm';
export const SEARCH_HISTORY_EMPTY_STATE_CLASS = 'px-3 py-2 text-xs text-muted';
export const SEARCH_HISTORY_ROW_CLASS =
'flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900';
export const SEARCH_HISTORY_ENTRY_BUTTON_CLASS =
'flex-1 truncate pr-2 text-left text-sm text-base-content transition-colors hover:text-blue-600 focus:outline-none dark:hover:text-blue-300';
export const SEARCH_HISTORY_CLEAR_LABEL = 'Clear history';
export function getSearchHistoryToggleButtonClass(isOpen: boolean): string {
return `flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
isOpen
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`;
}
export function getSearchHistoryToggleTitle(historyCount: number): string {
return historyCount > 0 ? 'Show recent searches' : 'No recent searches yet';
}
export function getSearchHistoryDeleteButtonClass(): string {
return 'ml-1 flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none';
}
export function getSearchHistoryClearButtonClass(): string {
return 'flex w-full items-center justify-center gap-2 border-t border-border px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
}
@@ -43,6 +43,8 @@ import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw';
import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw';
import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw';
@@ -82,6 +84,7 @@ import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.
import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw';
import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw';
import toggleStateSource from '@/components/shared/useToggleState.ts?raw';
@@ -2726,6 +2729,22 @@ describe('frontend resource type boundaries', () => {
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsSource).not.toContain('No recent searches yet');
expect(searchInputEnhancementsSource).not.toContain('Clear history');
expect(searchInputEnhancementsSource).not.toContain('hover:bg-blue-50');
expect(searchInputEnhancementsStateSource).toContain('createSearchHistoryManager');
expect(searchInputEnhancementsStateSource).not.toContain('Show recent searches');
expect(searchInputEnhancementsStateSource).toContain(
"options.history?.emptyMessage ?? 'Searches you run will appear here.'",
);
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleButtonClass');
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS');
expect(searchTipsPopoverSource).toContain('useSearchTipsPopoverState');
expect(searchTipsPopoverSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverSource).not.toContain('createSignal');