Register shared search control governance

This commit is contained in:
rcourtman
2026-06-12 15:07:50 +01:00
parent 527514fd85
commit b4a3fb868d
7 changed files with 184 additions and 24 deletions
@@ -2752,7 +2752,11 @@ keyboard-selectable. The button, slash-triggered help, dialog search, and
keyboard selection must use the same `ASSISTANT_SLASH_COMMANDS` registry,
`filterAssistantSlashCommands`, `AssistantCommandHelpDialog`, and
`executeSlashCommand` path as `/help` and `/commands`; they must not introduce
a second command registry or a separate provider-bound prompt path.
a second command registry or a separate provider-bound prompt path. The command
dialog search box must compose the frontend-primitives `SearchField` so the
shared search-control registry owns clear-button behavior, input chrome,
keyboard forwarding, and native-search-input drift prevention while AI runtime
keeps ownership of command filtering and execution semantics.
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and
@@ -2416,6 +2416,12 @@ small leading status-dot glyph used by filter options. Page and feature
surfaces must not copy the chip-dot `<span>` factory or import the legacy
`PageControls` deck for resource-list filtering; those drift checks live in
`shared-template-registry.json` and run through `shared-template-audit.mjs`.
Search controls are registry-backed too. `SearchField` owns simple search
input chrome, clear affordance, keyboard forwarding, focus handling, aria
labels, and trailing-control padding, while `SearchInput` owns resource-list
search enhancements such as history, tips, and type-to-search wiring. Product
surfaces must compose those primitives instead of rendering native
`type="search"` inputs or recreating search icon/clear/input classes locally.
Segmented selectors are registry-backed too. `FilterButtonGroup` owns the
settings, prominent, compact, and equal segmented selector shells, including
active-button tone, disabled-option behavior, pressed-state semantics,
@@ -281,6 +281,51 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "search-field-shell",
"category": "search-control",
"summary": "Product surfaces with simple search fields must compose SearchField for the shared icon, clear button, keyboard, focus, aria, and input chrome instead of rendering native search inputs.",
"canonical": {
"path": "src/components/shared/SearchField.tsx",
"export": "SearchField"
},
"requiredConsumers": [
{ "path": "src/components/AI/Chat/AssistantCommandHelpDialog.tsx" },
{ "path": "src/components/AI/Chat/index.tsx" },
{ "path": "src/components/Settings/InfrastructureSourcePicker.tsx" },
{ "path": "src/components/Settings/ResourcePicker.tsx" },
{ "path": "src/components/Settings/UserAssignmentsPanel.tsx" },
{ "path": "src/components/shared/AIModelPicker.tsx" },
{ "path": "src/components/shared/CommandPaletteModal.tsx" },
{ "path": "src/components/shared/SearchInput.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/SearchField.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "search-input-shell",
"category": "search-control",
"summary": "Resource-list and drawer search boxes that need history, tips, or type-to-search behavior must compose SearchInput instead of rebuilding search-enhancement controls locally.",
"canonical": {
"path": "src/components/shared/SearchInput.tsx",
"export": "SearchInput"
},
"requiredConsumers": [
{ "path": "src/components/Docker/SwarmServicesDrawer.tsx" },
{ "path": "src/components/Kubernetes/K8sDeploymentsDrawer.tsx" },
{ "path": "src/components/Kubernetes/K8sNamespacesDrawer.tsx" },
{ "path": "src/components/Settings/SettingsPageShell.tsx" },
{ "path": "src/components/shared/FilterBar/FilterBar.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/SearchInput.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-button-group-shell",
"category": "segmented-selector",
@@ -597,6 +642,25 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "search-control-native-search-input",
"category": "search-control",
"summary": "Product surfaces must not render native type=search inputs; they must compose SearchField or SearchInput so search chrome, clear behavior, keyboard handling, and aria labels stay canonical.",
"canonical": {
"path": "src/components/shared/SearchField.tsx",
"export": "SearchField"
},
"scopes": ["src/components", "src/features", "src/pages"],
"extensions": [".tsx"],
"allPatterns": ["type=\"search\""],
"legacyReason": "Retired migration debt. Search controls must use SearchField or SearchInput; native search inputs are not allowed in product surfaces.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-chip-status-dot-local-factory",
"category": "filter-bar",
@@ -15,6 +15,7 @@ import {
AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
AI_CHAT_COMMAND_HELP_TITLE,
} from '@/utils/aiChatPresentation';
import { SearchField } from '@/components/shared/SearchField';
interface AssistantCommandHelpDialogProps {
availability?: AssistantSlashCommandAvailability;
@@ -139,14 +140,15 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
</button>
</div>
<div class="border-b border-border px-3 py-2">
<input
ref={searchInputRef}
<SearchField
inputRef={(element) => {
searchInputRef = element;
}}
value={commandSearchQuery()}
onInput={(event) => setCommandSearchQuery(event.currentTarget.value)}
type="search"
aria-label={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
onChange={setCommandSearchQuery}
title={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
placeholder={AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER}
class="h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-base-content placeholder:text-muted focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
onKeyDown={handleKeyDown}
/>
</div>
<div
@@ -10,9 +10,12 @@ describe('AssistantCommandHelpDialog', () => {
const search = screen.getByLabelText('Search Assistant commands');
await waitFor(() => expect(document.activeElement).toBe(search));
expect(search).toHaveAttribute('type', 'text');
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
fireEvent.input(search, { target: { value: 'runtime' } });
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument();
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /\/models/ })).not.toBeInTheDocument();
@@ -5,6 +5,7 @@ import type {
InfrastructureSourcePickerItemPresentation,
InfrastructureSourcePickerRouteStep,
} from '@/utils/infrastructureOnboardingPresentation';
import { SearchField } from '@/components/shared/SearchField';
import {
getInfrastructureGovernanceBadgeLabel,
getInfrastructureSourcePickerItems,
@@ -18,9 +19,6 @@ interface InfrastructureSourcePickerProps {
const readinessBadgeClass =
'inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200';
const searchInputClass =
'h-10 w-full rounded-md border border-border bg-surface py-2 pl-9 pr-3 text-sm text-base-content outline-none transition-colors placeholder:text-muted focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20';
// Primary-path cards present the main onboarding journeys up front so users
// pick a path before scanning the per-source card grid. The grid below stays
// as a direct alternative for users who already know which source they want.
@@ -150,20 +148,12 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
</Show>
<div class="border-b border-border pb-4">
<label class="relative block">
<span class="sr-only">Search infrastructure type</span>
<Search
aria-hidden="true"
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted"
/>
<input
type="search"
value={query()}
onInput={(event) => setQuery(event.currentTarget.value)}
class={searchInputClass}
placeholder="Search sources, devices, services..."
/>
</label>
<SearchField
value={query()}
onChange={setQuery}
title="Search infrastructure type"
placeholder="Search sources, devices, services..."
/>
</div>
<section class="space-y-2">
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import calloutCardSource from '@/components/shared/CalloutCard.tsx?raw';
import assistantCommandHelpDialogSource from '@/components/AI/Chat/AssistantCommandHelpDialog.tsx?raw';
import aiChatSource from '@/components/AI/Chat/index.tsx?raw';
import aiModelPickerSource from '@/components/shared/AIModelPicker.tsx?raw';
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
@@ -155,6 +157,9 @@ import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
import infrastructureSourcePickerSource from '@/components/Settings/InfrastructureSourcePicker.tsx?raw';
import resourcePickerSource from '@/components/Settings/ResourcePicker.tsx?raw';
import settingsPageShellSource from '@/components/Settings/SettingsPageShell.tsx?raw';
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw';
import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw';
@@ -1871,6 +1876,49 @@ describe('shared primitive guardrails', () => {
});
it('keeps search field on shell, runtime, and model owners', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
}>;
patternGuards?: Array<{
id: string;
canonical?: { path?: string; export?: string };
allPatterns?: string[];
scopes?: string[];
allowedPaths?: string[];
ignoredPaths?: string[];
}>;
};
const registeredRule = registry.rules?.find((rule) => rule.id === 'search-field-shell');
const registeredGuard = registry.patternGuards?.find(
(guard) => guard.id === 'search-control-native-search-input',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/SearchField.tsx');
expect(registeredRule?.canonical?.export).toBe('SearchField');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/AI/Chat/AssistantCommandHelpDialog.tsx',
'src/components/AI/Chat/index.tsx',
'src/components/Settings/InfrastructureSourcePicker.tsx',
'src/components/Settings/ResourcePicker.tsx',
'src/components/Settings/UserAssignmentsPanel.tsx',
'src/components/shared/AIModelPicker.tsx',
'src/components/shared/CommandPaletteModal.tsx',
'src/components/shared/SearchInput.tsx',
]);
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/SearchField.tsx');
expect(registeredGuard?.canonical?.export).toBe('SearchField');
expect(registeredGuard?.allPatterns).toEqual(['type="search"']);
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(registeredGuard?.ignoredPaths).toEqual([
'src/components/shared/SharedPrimitives.guardrails.test.ts',
]);
expect(registeredGuard?.scopes).toEqual(
expect.arrayContaining(['src/components', 'src/features', 'src/pages']),
);
expect(searchFieldSource).toContain('useSearchFieldState');
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
expect(searchFieldSource).not.toContain(
@@ -1888,9 +1936,42 @@ describe('shared primitive guardrails', () => {
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
for (const source of [
assistantCommandHelpDialogSource,
aiChatSource,
infrastructureSourcePickerSource,
resourcePickerSource,
userAssignmentsPanelSource,
aiModelPickerSource,
commandPaletteModalSource,
searchInputSource,
]) {
expect(source).toContain('SearchField');
expect(source).not.toContain('type="search"');
}
});
it('keeps search input on shell, runtime, and model owners', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
}>;
};
const registeredRule = registry.rules?.find((rule) => rule.id === 'search-input-shell');
expect(registeredRule?.canonical?.path).toBe('src/components/shared/SearchInput.tsx');
expect(registeredRule?.canonical?.export).toBe('SearchInput');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/Docker/SwarmServicesDrawer.tsx',
'src/components/Kubernetes/K8sDeploymentsDrawer.tsx',
'src/components/Kubernetes/K8sNamespacesDrawer.tsx',
'src/components/Settings/SettingsPageShell.tsx',
'src/components/shared/FilterBar/FilterBar.tsx',
]);
expect(searchInputSource).toContain('useSearchInputState');
expect(searchInputSource).not.toContain('let searchInputEl: HTMLInputElement');
expect(searchInputSource).not.toContain('useTypeToSearch');
@@ -1928,6 +2009,16 @@ describe('shared primitive guardrails', () => {
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleTitle');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS');
for (const source of [
swarmServicesDrawerSource,
k8sDeploymentsDrawerSource,
k8sNamespacesDrawerSource,
settingsPageShellSource,
filterBarSource,
]) {
expect(source).toContain('SearchInput');
}
});
it('keeps search tips popover on shell, runtime, and model owners', () => {