diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 861b2c40c..6543ec57a 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -238,6 +238,14 @@ dropdown open state and outside-click listener lifecycle, and
count, reset visibility policy, and column-option text-class/copy policy.
Future column-picker work should extend those owners instead of pushing
document-level listener logic or column-count policy back into the shell.
+The shared tag input now follows that same owner split.
+`frontend-modern/src/components/shared/TagInput.tsx` stays the render shell,
+`frontend-modern/src/components/shared/useTagInputState.ts` owns input state,
+container-focus runtime, and tag add/remove/backspace orchestration, and
+`frontend-modern/src/components/shared/tagInputModel.ts` owns delimiter keys,
+placeholder policy, remove-title copy, and canonical next-tag derivation.
+Future tag-input work should extend those owners instead of pushing DOM reach-in
+or tag-mutation policy back into the shell.
The shared dialog now follows that same owner split.
`frontend-modern/src/components/shared/Dialog.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDialogState.ts` owns focus trap,
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index dafd44191..83539fa12 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -6,6 +6,8 @@ import activeUseTrialNudgeSource from '@/components/shared/ActiveUseTrialNudge.t
import activeUseTrialNudgeModelSource from '@/components/shared/activeUseTrialNudgeModel.ts?raw';
import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw';
import columnPickerModelSource from '@/components/shared/columnPickerModel.ts?raw';
+import tagInputSource from '@/components/shared/TagInput.tsx?raw';
+import tagInputModelSource from '@/components/shared/tagInputModel.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';
@@ -55,6 +57,7 @@ import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
import columnPickerStateSource from '@/components/shared/useColumnPickerState.ts?raw';
+import tagInputStateSource from '@/components/shared/useTagInputState.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';
@@ -230,6 +233,28 @@ describe('shared primitive guardrails', () => {
expect(columnPickerModelSource).toContain('getColumnPickerOptionTextClass');
});
+ it('keeps tag input on shell, runtime, and model owners', () => {
+ expect(tagInputSource).toContain('useTagInputState');
+ expect(tagInputSource).toContain('getTagInputPlaceholder');
+ expect(tagInputSource).not.toContain('createSignal');
+ expect(tagInputSource).not.toContain('querySelector');
+ expect(tagInputSource).not.toContain('Backspace');
+ expect(tagInputSource).not.toContain('addTag');
+
+ expect(tagInputStateSource).toContain('export function useTagInputState');
+ expect(tagInputStateSource).toContain('createSignal');
+ expect(tagInputStateSource).toContain('createMemo');
+ expect(tagInputStateSource).toContain('inputRef?.focus');
+ expect(tagInputStateSource).toContain("event.key === 'Backspace'");
+ expect(tagInputStateSource).toContain('commitTag');
+
+ expect(tagInputModelSource).toContain('TAG_INPUT_DELIMITER_KEYS');
+ expect(tagInputModelSource).toContain('isTagInputCommitKey');
+ expect(tagInputModelSource).toContain('getTagInputPlaceholder');
+ expect(tagInputModelSource).toContain('getNextTagsAfterRemove');
+ expect(tagInputModelSource).toContain('getTagInputRemoveTitle');
+ });
+
it('routes settings info callouts through CalloutCard', () => {
expect(calloutCardSource).toContain(
"type CalloutTone = 'danger' | 'info' | 'success' | 'warning'",
diff --git a/frontend-modern/src/components/shared/TagInput.test.tsx b/frontend-modern/src/components/shared/TagInput.test.tsx
new file mode 100644
index 000000000..62c22468e
--- /dev/null
+++ b/frontend-modern/src/components/shared/TagInput.test.tsx
@@ -0,0 +1,60 @@
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { TagInput } from './TagInput';
+import tagInputSource from './TagInput.tsx?raw';
+import tagInputModelSource from './tagInputModel.ts?raw';
+import tagInputStateSource from './useTagInputState.ts?raw';
+
+describe('TagInput', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('keeps tag input on shell, runtime, and model owners', () => {
+ expect(tagInputSource).toContain('useTagInputState');
+ expect(tagInputSource).toContain('getTagInputPlaceholder');
+ expect(tagInputSource).not.toContain('createSignal');
+ expect(tagInputSource).not.toContain('querySelector');
+ expect(tagInputSource).not.toContain('Backspace');
+ expect(tagInputSource).not.toContain('addTag');
+
+ expect(tagInputStateSource).toContain('export function useTagInputState');
+ expect(tagInputStateSource).toContain('createSignal');
+ expect(tagInputStateSource).toContain('createMemo');
+ expect(tagInputStateSource).toContain('inputRef?.focus');
+ expect(tagInputStateSource).toContain("event.key === 'Backspace'");
+ expect(tagInputStateSource).toContain('commitTag');
+
+ expect(tagInputModelSource).toContain('TAG_INPUT_DELIMITER_KEYS');
+ expect(tagInputModelSource).toContain('isTagInputCommitKey');
+ expect(tagInputModelSource).toContain('getTagInputPlaceholder');
+ expect(tagInputModelSource).toContain('getNextTagsAfterRemove');
+ expect(tagInputModelSource).toContain('getTagInputRemoveTitle');
+ });
+
+ it('adds tags on enter and removes the last one on backspace when empty', () => {
+ const onChange = vi.fn();
+
+ render(() => );
+
+ const input = screen.getByRole('textbox');
+
+ fireEvent.input(input, { target: { value: 'beta' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onChange).toHaveBeenCalledWith(['alpha', 'beta']);
+
+ fireEvent.input(input, { target: { value: '' } });
+ fireEvent.keyDown(input, { key: 'Backspace' });
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+
+ it('focuses the input when the container is clicked and hides placeholder when tags exist', () => {
+ render(() => );
+
+ const input = screen.getByRole('textbox') as HTMLInputElement;
+ expect(input.placeholder).toBe('');
+
+ fireEvent.click(input.parentElement as HTMLElement);
+ expect(document.activeElement).toBe(input);
+ });
+});
diff --git a/frontend-modern/src/components/shared/TagInput.tsx b/frontend-modern/src/components/shared/TagInput.tsx
index 18ce3969a..2bae10e47 100644
--- a/frontend-modern/src/components/shared/TagInput.tsx
+++ b/frontend-modern/src/components/shared/TagInput.tsx
@@ -1,65 +1,28 @@
-import { createSignal, For } from 'solid-js';
+import { For } from 'solid-js';
import X from 'lucide-solid/icons/x';
-
-export interface TagInputProps {
- tags: string[];
- onChange: (tags: string[]) => void;
- placeholder?: string;
- class?: string;
-}
+import {
+ getTagInputPlaceholder,
+ getTagInputRemoveTitle,
+ TAG_INPUT_FIELD_CLASS,
+ TAG_INPUT_REMOVE_BUTTON_CLASS,
+ TAG_INPUT_TAG_CLASS,
+} from '@/components/shared/tagInputModel';
+import { type TagInputProps, useTagInputState } from '@/components/shared/useTagInputState';
export function TagInput(props: TagInputProps) {
- const [inputValue, setInputValue] = createSignal('');
-
- const handleKeyDown = (e: KeyboardEvent) => {
- // Prevent default on enter/comma to use them as delimiters
- if (e.key === 'Enter' || e.key === ',') {
- e.preventDefault();
- addTag();
- } else if (e.key === 'Backspace' && !inputValue() && props.tags.length > 0) {
- // Remove last tag if backspace is pressed while input is empty
- props.onChange(props.tags.slice(0, -1));
- }
- };
-
- const handleBlur = () => {
- // Also try adding the tag on blur
- addTag();
- };
-
- const addTag = () => {
- const newTag = inputValue().trim();
- if (newTag && !props.tags.includes(newTag)) {
- props.onChange([...props.tags, newTag]);
- }
- setInputValue('');
- };
-
- const removeTag = (indexToRemove: number) => {
- props.onChange(props.tags.filter((_, i) => i !== indexToRemove));
- };
+ const state = useTagInputState(props);
return (
-
{
- // Focus the input when clicking anywhere in the container
- const input = e.currentTarget.querySelector('input');
- if (input) input.focus();
- }}
- >
+
{(tag, index) => (
-
+
{tag}
@@ -67,13 +30,14 @@ export function TagInput(props: TagInputProps) {
)}
setInputValue(e.currentTarget.value)}
- onKeyDown={handleKeyDown}
- onBlur={handleBlur}
- placeholder={props.tags.length === 0 ? props.placeholder : ''}
- class="flex-1 bg-transparent min-w-[120px] focus:outline-none"
+ value={state.inputValue()}
+ onInput={state.handleInput}
+ onKeyDown={state.handleKeyDown}
+ onBlur={state.handleBlur}
+ placeholder={getTagInputPlaceholder(props.tags.length, props.placeholder)}
+ class={TAG_INPUT_FIELD_CLASS}
/>
);
diff --git a/frontend-modern/src/components/shared/tagInputModel.ts b/frontend-modern/src/components/shared/tagInputModel.ts
new file mode 100644
index 000000000..c0b6b42ab
--- /dev/null
+++ b/frontend-modern/src/components/shared/tagInputModel.ts
@@ -0,0 +1,40 @@
+export const TAG_INPUT_CONTAINER_CLASS =
+ 'min-h-[42px] flex flex-wrap items-center gap-2 rounded-md border border-border bg-surface p-2 text-sm text-base-content focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500';
+export const TAG_INPUT_TAG_CLASS =
+ 'inline-flex items-center gap-1 rounded bg-surface-alt px-2 py-1 text-xs font-medium text-base-content';
+export const TAG_INPUT_REMOVE_BUTTON_CLASS =
+ 'rounded-full p-0.5 text-slate-400 hover:bg-slate-300 hover:bg-surface-hover cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-500';
+export const TAG_INPUT_FIELD_CLASS = 'flex-1 bg-transparent min-w-[120px] focus:outline-none';
+export const TAG_INPUT_DELIMITER_KEYS = ['Enter', ','] as const;
+
+export function isTagInputCommitKey(key: string): boolean {
+ return TAG_INPUT_DELIMITER_KEYS.includes(key as (typeof TAG_INPUT_DELIMITER_KEYS)[number]);
+}
+
+export function getTagInputPlaceholder(tagCount: number, placeholder?: string): string {
+ return tagCount === 0 ? (placeholder ?? '') : '';
+}
+
+export function normalizeTagInputValue(value: string): string {
+ return value.trim();
+}
+
+export function canAddTag(tags: string[], value: string): boolean {
+ return Boolean(value) && !tags.includes(value);
+}
+
+export function getNextTagsAfterAdd(tags: string[], value: string): string[] {
+ return [...tags, value];
+}
+
+export function getNextTagsAfterBackspace(tags: string[]): string[] {
+ return tags.slice(0, -1);
+}
+
+export function getNextTagsAfterRemove(tags: string[], indexToRemove: number): string[] {
+ return tags.filter((_, index) => index !== indexToRemove);
+}
+
+export function getTagInputRemoveTitle(tag: string): string {
+ return `Remove ${tag}`;
+}
diff --git a/frontend-modern/src/components/shared/useTagInputState.ts b/frontend-modern/src/components/shared/useTagInputState.ts
new file mode 100644
index 000000000..696646216
--- /dev/null
+++ b/frontend-modern/src/components/shared/useTagInputState.ts
@@ -0,0 +1,66 @@
+import { createMemo, createSignal } from 'solid-js';
+import {
+ canAddTag,
+ getNextTagsAfterAdd,
+ getNextTagsAfterBackspace,
+ getNextTagsAfterRemove,
+ isTagInputCommitKey,
+ normalizeTagInputValue,
+ TAG_INPUT_CONTAINER_CLASS,
+} from '@/components/shared/tagInputModel';
+
+export interface TagInputProps {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ placeholder?: string;
+ class?: string;
+}
+
+export function useTagInputState(props: TagInputProps) {
+ const [inputValue, setInputValue] = createSignal('');
+ let inputRef: HTMLInputElement | undefined;
+
+ const commitTag = () => {
+ const normalizedValue = normalizeTagInputValue(inputValue());
+ if (canAddTag(props.tags, normalizedValue)) {
+ props.onChange(getNextTagsAfterAdd(props.tags, normalizedValue));
+ }
+ setInputValue('');
+ };
+
+ const containerClass = createMemo(() =>
+ `${TAG_INPUT_CONTAINER_CLASS} ${props.class ?? ''}`.trim(),
+ );
+
+ return {
+ containerClass,
+ handleBlur: () => {
+ commitTag();
+ },
+ handleContainerClick: () => {
+ inputRef?.focus();
+ },
+ handleInput: (event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => {
+ setInputValue(event.currentTarget.value);
+ },
+ handleKeyDown: (event: KeyboardEvent) => {
+ if (isTagInputCommitKey(event.key)) {
+ event.preventDefault();
+ commitTag();
+ return;
+ }
+
+ if (event.key === 'Backspace' && !inputValue() && props.tags.length > 0) {
+ props.onChange(getNextTagsAfterBackspace(props.tags));
+ }
+ },
+ handleRemoveTag: (event: MouseEvent, indexToRemove: number) => {
+ event.stopPropagation();
+ props.onChange(getNextTagsAfterRemove(props.tags, indexToRemove));
+ },
+ inputValue,
+ setInputRef: (element: HTMLInputElement) => {
+ inputRef = element;
+ },
+ };
+}
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index d872ae019..c57bd5048 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -14,6 +14,8 @@ import activeUseTrialNudgeSource from '@/components/shared/ActiveUseTrialNudge.t
import activeUseTrialNudgeModelSource from '@/components/shared/activeUseTrialNudgeModel.ts?raw';
import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw';
import columnPickerModelSource from '@/components/shared/columnPickerModel.ts?raw';
+import tagInputSource from '@/components/shared/TagInput.tsx?raw';
+import tagInputModelSource from '@/components/shared/tagInputModel.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';
@@ -57,6 +59,7 @@ import sharedInfrastructureSummaryTableModelSource from '@/components/shared/inf
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
import columnPickerStateSource from '@/components/shared/useColumnPickerState.ts?raw';
+import tagInputStateSource from '@/components/shared/useTagInputState.ts?raw';
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
import dialogStateSource from '@/components/shared/useDialogState.ts?raw';
@@ -2773,6 +2776,22 @@ describe('frontend resource type boundaries', () => {
expect(columnPickerModelSource).toContain('getHiddenColumnCount');
expect(columnPickerModelSource).toContain('shouldShowColumnPickerReset');
expect(columnPickerModelSource).toContain('getColumnPickerOptionTextClass');
+ expect(tagInputSource).toContain('useTagInputState');
+ expect(tagInputSource).toContain('getTagInputPlaceholder');
+ expect(tagInputSource).not.toContain('createSignal');
+ expect(tagInputSource).not.toContain('querySelector');
+ expect(tagInputSource).not.toContain('Backspace');
+ expect(tagInputSource).not.toContain('addTag');
+ expect(tagInputStateSource).toContain('createSignal');
+ expect(tagInputStateSource).toContain('createMemo');
+ expect(tagInputStateSource).toContain('inputRef?.focus');
+ expect(tagInputStateSource).toContain("event.key === 'Backspace'");
+ expect(tagInputStateSource).toContain('commitTag');
+ expect(tagInputModelSource).toContain('TAG_INPUT_DELIMITER_KEYS');
+ expect(tagInputModelSource).toContain('isTagInputCommitKey');
+ expect(tagInputModelSource).toContain('getTagInputPlaceholder');
+ expect(tagInputModelSource).toContain('getNextTagsAfterRemove');
+ expect(tagInputModelSource).toContain('getTagInputRemoveTitle');
expect(monitoredSystemLimitWarningBannerSource).toContain(
'useMonitoredSystemLimitWarningBannerState',
);