Split shared search tips popover owners

This commit is contained in:
rcourtman 2026-03-23 08:27:26 +00:00
parent 035f68e029
commit 326b02842e
7 changed files with 322 additions and 107 deletions

View file

@ -294,6 +294,14 @@ 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 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`
owns open-state, pointer/focus continuity, and outside-click/Escape listener
runtime, and `frontend-modern/src/components/shared/searchTipsPopoverModel.ts`
owns trigger variant, label/id defaults, hover policy, and trigger/popover
class selection. Future search-tips work should extend those owners instead of
pushing listener lifecycle or trigger policy back into the shared shell.
The shared tooltip now follows that same owner split.
`frontend-modern/src/components/shared/Tooltip.tsx` stays the render shell and
singleton API boundary, `frontend-modern/src/components/shared/useTooltipState.ts`

View file

@ -1,111 +1,49 @@
import { Component, Show, For, createEffect, createSignal, onCleanup } from 'solid-js';
import { Component, Show, For } from 'solid-js';
import {
getSearchTipsPopoverButtonLabel,
getSearchTipsPopoverId,
getSearchTipsPopoverPositionClass,
getSearchTipsPopoverTitle,
getSearchTipsPopoverTriggerClass,
getSearchTipsPopoverTriggerVariant,
shouldSearchTipsPopoverOpenOnHover,
type SearchTipsPopoverProps,
} from './searchTipsPopoverModel';
import { useSearchTipsPopoverState } from './useSearchTipsPopoverState';
interface SearchTip {
code: string;
description: string;
}
interface SearchTipsPopoverProps {
buttonLabel?: string;
title?: string;
intro?: string;
tips: SearchTip[];
footerText?: string;
footerHighlight?: string;
popoverId?: string;
align?: 'left' | 'right';
class?: string;
triggerVariant?: 'button' | 'link' | 'icon';
openOnHover?: boolean;
}
export type { SearchTip, SearchTipsPopoverProps } from './searchTipsPopoverModel';
export const SearchTipsPopover: Component<SearchTipsPopoverProps> = (props) => {
const [open, setOpen] = createSignal(false);
let popoverRef: HTMLDivElement | undefined;
let triggerRef: HTMLButtonElement | undefined;
let pointerInside = false;
const close = () => setOpen(false);
createEffect(() => {
if (!open()) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const insidePopover = popoverRef?.contains(target) ?? false;
const onTrigger = triggerRef?.contains(target) ?? false;
if (!insidePopover && !onTrigger) {
close();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
});
const triggerVariant = () => getSearchTipsPopoverTriggerVariant(props.triggerVariant);
const buttonLabel = () => getSearchTipsPopoverButtonLabel(props.buttonLabel);
const title = () => getSearchTipsPopoverTitle(props.title);
const popoverId = () => getSearchTipsPopoverId(props.popoverId);
const positionClass = () => getSearchTipsPopoverPositionClass(props.align);
const triggerClass = () => getSearchTipsPopoverTriggerClass(triggerVariant());
const openOnHover = () => shouldSearchTipsPopoverOpenOnHover(props.openOnHover);
const state = useSearchTipsPopoverState({
buttonLabel,
openOnHover,
});
const popoverPositionClass = props.align === 'left' ? 'left-0' : 'right-0';
const popoverId = props.popoverId ?? 'search-tips-popover';
const triggerVariant = props.triggerVariant ?? 'button';
const openOnHover = props.openOnHover ?? false;
const buttonLabel = props.buttonLabel ?? 'Search tips';
const triggerBaseClasses =
'text-xs font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 focus:ring-offset-white dark:focus:ring-blue-400';
const triggerClasses =
triggerVariant === 'button'
? `rounded-md border border-border px-2.5 py-1 text-muted transition-colors hover:bg-surface-hover ${triggerBaseClasses}`
: triggerVariant === 'link'
? `rounded px-1 py-0.5 underline decoration-dotted underline-offset-4 transition-colors hover:text-base-content ${triggerBaseClasses}`
: `flex h-5 w-5 items-center justify-center rounded-full transition-colors hover:text-muted ${triggerBaseClasses}`;
const handleMouseEnter = () => {
pointerInside = true;
setOpen(true);
};
const handleMouseLeave = () => {
pointerInside = false;
setOpen(false);
};
return (
<div
class={`relative ${props.class ?? ''}`}
onMouseEnter={openOnHover ? handleMouseEnter : undefined}
onMouseLeave={openOnHover ? handleMouseLeave : undefined}
onMouseEnter={openOnHover() ? state.handleMouseEnter : undefined}
onMouseLeave={openOnHover() ? state.handleMouseLeave : undefined}
>
<button
ref={(el) => (triggerRef = el)}
ref={state.setTriggerRef}
type="button"
class={triggerClasses}
onClick={openOnHover ? () => setOpen(true) : () => setOpen((value) => !value)}
onFocus={() => setOpen(true)}
onBlur={() => {
if (!pointerInside) {
setOpen(false);
}
}}
aria-expanded={open()}
aria-controls={popoverId}
aria-label={buttonLabel}
class={triggerClass()}
onClick={state.handleClick}
onFocus={state.handleMouseEnter}
onBlur={state.handleBlur}
aria-expanded={state.isOpen()}
aria-controls={popoverId()}
aria-label={buttonLabel()}
>
{triggerVariant === 'icon' ? (
{triggerVariant() === 'icon' ? (
<>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@ -115,29 +53,27 @@ export const SearchTipsPopover: Component<SearchTipsPopoverProps> = (props) => {
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">{buttonLabel}</span>
<span class="sr-only">{buttonLabel()}</span>
</>
) : (
buttonLabel
buttonLabel()
)}
</button>
<Show when={open()}>
<Show when={state.isOpen()}>
<div
ref={(el) => (popoverRef = el)}
id={popoverId}
ref={state.setPopoverRef}
id={popoverId()}
role="dialog"
aria-label={props.title ?? 'Search tips'}
class={`absolute ${popoverPositionClass} z-50 mt-2 w-72 overflow-hidden rounded-md border bg-surface text-left shadow-sm`}
aria-label={title()}
class={`absolute ${positionClass()} z-50 mt-2 w-72 overflow-hidden rounded-md border bg-surface text-left shadow-sm`}
>
<div class="flex items-center justify-between border-b border-border-subtle px-3 py-2">
<span class="text-sm font-semibold text-base-content">
{props.title ?? 'Search tips'}
</span>
<span class="text-sm font-semibold text-base-content">{title()}</span>
<button
type="button"
class="rounded p-1 transition-colors hover:text-muted"
onClick={close}
onClick={state.close}
aria-label="Close search tips"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View file

@ -29,6 +29,8 @@ 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 searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw';
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
@ -54,6 +56,7 @@ import infrastructureSelectorStateSource from '@/components/shared/useInfrastruc
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
@ -490,6 +493,26 @@ describe('shared primitive guardrails', () => {
expect(searchInputModelSource).toContain('export interface SearchInputProps');
});
it('keeps search tips popover on shell, runtime, and model owners', () => {
expect(searchTipsPopoverSource).toContain('useSearchTipsPopoverState');
expect(searchTipsPopoverSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverSource).not.toContain('createSignal');
expect(searchTipsPopoverSource).not.toContain('createEffect');
expect(searchTipsPopoverSource).not.toContain('window.addEventListener');
expect(searchTipsPopoverSource).not.toContain('triggerVariant ===');
expect(searchTipsPopoverStateSource).toContain('export function useSearchTipsPopoverState');
expect(searchTipsPopoverStateSource).toContain('createSignal');
expect(searchTipsPopoverStateSource).toContain('createEffect');
expect(searchTipsPopoverStateSource).toContain('window.addEventListener');
expect(searchTipsPopoverStateSource).toContain('pointerInside');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerVariant');
expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover');
});
it('keeps tooltip on shell, runtime, and model owners', () => {
expect(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');

View file

@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw';
import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw';
describe('SearchTipsPopover', () => {
afterEach(() => {
cleanup();
});
it('keeps search tips popover on shell, runtime, and model owners', () => {
expect(searchTipsPopoverSource).toContain('useSearchTipsPopoverState');
expect(searchTipsPopoverSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverSource).not.toContain('createSignal');
expect(searchTipsPopoverSource).not.toContain('createEffect');
expect(searchTipsPopoverSource).not.toContain('window.addEventListener');
expect(searchTipsPopoverSource).not.toContain('triggerVariant ===');
expect(searchTipsPopoverStateSource).toContain('export function useSearchTipsPopoverState');
expect(searchTipsPopoverStateSource).toContain('createSignal');
expect(searchTipsPopoverStateSource).toContain('createEffect');
expect(searchTipsPopoverStateSource).toContain('window.addEventListener');
expect(searchTipsPopoverStateSource).toContain('pointerInside');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerVariant');
expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover');
});
it('toggles the popover on click by default', async () => {
render(() => (
<SearchTipsPopover
tips={[{ code: 'name:web', description: 'Filter by name' }]}
/>
));
const trigger = screen.getByRole('button', { name: 'Search tips' });
expect(screen.queryByRole('dialog', { name: 'Search tips' })).toBeNull();
fireEvent.click(trigger);
expect(await screen.findByRole('dialog', { name: 'Search tips' })).toBeInTheDocument();
fireEvent.click(trigger);
expect(screen.queryByRole('dialog', { name: 'Search tips' })).toBeNull();
});
it('opens on hover when configured', async () => {
render(() => (
<SearchTipsPopover
openOnHover
tips={[{ code: 'tag:web', description: 'Filter by tag' }]}
/>
));
const trigger = screen.getByRole('button', { name: 'Search tips' });
fireEvent.mouseEnter(trigger.parentElement as HTMLElement);
expect(await screen.findByRole('dialog', { name: 'Search tips' })).toBeInTheDocument();
fireEvent.mouseLeave(trigger.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: 'Search tips' })).toBeNull();
});
it('closes on Escape while open', async () => {
render(() => (
<SearchTipsPopover
tips={[{ code: 'cpu>80', description: 'Filter by CPU threshold' }]}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Search tips' }));
expect(await screen.findByRole('dialog', { name: 'Search tips' })).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Search tips' })).toBeNull();
});
});

View file

@ -0,0 +1,61 @@
export interface SearchTip {
code: string;
description: string;
}
export interface SearchTipsPopoverProps {
buttonLabel?: string;
title?: string;
intro?: string;
tips: SearchTip[];
footerText?: string;
footerHighlight?: string;
popoverId?: string;
align?: 'left' | 'right';
class?: string;
triggerVariant?: 'button' | 'link' | 'icon';
openOnHover?: boolean;
}
export function getSearchTipsPopoverPositionClass(align?: 'left' | 'right'): string {
return align === 'left' ? 'left-0' : 'right-0';
}
export function getSearchTipsPopoverId(popoverId?: string): string {
return popoverId ?? 'search-tips-popover';
}
export function getSearchTipsPopoverButtonLabel(buttonLabel?: string): string {
return buttonLabel ?? 'Search tips';
}
export function getSearchTipsPopoverTitle(title?: string): string {
return title ?? 'Search tips';
}
export function getSearchTipsPopoverTriggerVariant(
triggerVariant?: 'button' | 'link' | 'icon',
): 'button' | 'link' | 'icon' {
return triggerVariant ?? 'button';
}
export function shouldSearchTipsPopoverOpenOnHover(openOnHover?: boolean): boolean {
return openOnHover ?? false;
}
export function getSearchTipsPopoverTriggerClass(
triggerVariant: 'button' | 'link' | 'icon',
): string {
const triggerBaseClasses =
'text-xs font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 focus:ring-offset-white dark:focus:ring-blue-400';
if (triggerVariant === 'button') {
return `rounded-md border border-border px-2.5 py-1 text-muted transition-colors hover:bg-surface-hover ${triggerBaseClasses}`;
}
if (triggerVariant === 'link') {
return `rounded px-1 py-0.5 underline decoration-dotted underline-offset-4 transition-colors hover:text-base-content ${triggerBaseClasses}`;
}
return `flex h-5 w-5 items-center justify-center rounded-full transition-colors hover:text-muted ${triggerBaseClasses}`;
}

View file

@ -0,0 +1,92 @@
import { createEffect, createSignal, onCleanup } from 'solid-js';
import type { Accessor } from 'solid-js';
interface SearchTipsPopoverState {
buttonLabel: Accessor<string>;
close: () => void;
handleBlur: () => void;
handleClick: () => void;
handleMouseEnter: () => void;
handleMouseLeave: () => void;
isOpen: Accessor<boolean>;
setPopoverRef: (el: HTMLDivElement) => void;
setTriggerRef: (el: HTMLButtonElement) => void;
}
interface SearchTipsPopoverStateOptions {
buttonLabel: Accessor<string>;
openOnHover: Accessor<boolean>;
}
export function useSearchTipsPopoverState(
options: SearchTipsPopoverStateOptions,
): SearchTipsPopoverState {
const [open, setOpen] = createSignal(false);
let popoverRef: HTMLDivElement | undefined;
let triggerRef: HTMLButtonElement | undefined;
let pointerInside = false;
const close = () => setOpen(false);
createEffect(() => {
if (!open()) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const insidePopover = popoverRef?.contains(target) ?? false;
const onTrigger = triggerRef?.contains(target) ?? false;
if (!insidePopover && !onTrigger) {
close();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
});
});
return {
buttonLabel: options.buttonLabel,
close,
handleBlur: () => {
if (!pointerInside) {
setOpen(false);
}
},
handleClick: () => {
if (options.openOnHover()) {
setOpen(true);
return;
}
setOpen((value) => !value);
},
handleMouseEnter: () => {
pointerInside = true;
setOpen(true);
},
handleMouseLeave: () => {
pointerInside = false;
setOpen(false);
},
isOpen: open,
setPopoverRef: (el) => {
popoverRef = el;
},
setTriggerRef: (el) => {
triggerRef = el;
},
};
}

View file

@ -34,6 +34,8 @@ 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 searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw';
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
@ -53,6 +55,7 @@ import infrastructureSelectorStateSource from '@/components/shared/useInfrastruc
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
@ -2690,6 +2693,19 @@ describe('frontend resource type boundaries', () => {
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(searchTipsPopoverSource).toContain('useSearchTipsPopoverState');
expect(searchTipsPopoverSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverSource).not.toContain('createSignal');
expect(searchTipsPopoverSource).not.toContain('createEffect');
expect(searchTipsPopoverSource).not.toContain('window.addEventListener');
expect(searchTipsPopoverStateSource).toContain('createSignal');
expect(searchTipsPopoverStateSource).toContain('createEffect');
expect(searchTipsPopoverStateSource).toContain('window.addEventListener');
expect(searchTipsPopoverStateSource).toContain('pointerInside');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerVariant');
expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover');
expect(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');
expect(tooltipSource).not.toContain('createSignal');