Split tag input runtime owners

This commit is contained in:
rcourtman 2026-03-23 09:13:33 +00:00
parent 90568bfeb8
commit 6f2bb85373
7 changed files with 240 additions and 58 deletions

View file

@ -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,

View file

@ -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'",

View file

@ -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(() => <TagInput tags={['alpha']} onChange={onChange} placeholder="Add tag" />);
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(() => <TagInput tags={['alpha']} onChange={vi.fn()} placeholder="Add tag" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.placeholder).toBe('');
fireEvent.click(input.parentElement as HTMLElement);
expect(document.activeElement).toBe(input);
});
});

View file

@ -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 (
<div
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 ${props.class || ''}`}
onClick={(e) => {
// Focus the input when clicking anywhere in the container
const input = e.currentTarget.querySelector('input');
if (input) input.focus();
}}
>
<div class={state.containerClass()} onClick={state.handleContainerClick}>
<For each={props.tags}>
{(tag, index) => (
<span class="inline-flex items-center gap-1 rounded bg-surface-alt px-2 py-1 text-xs font-medium text-base-content">
<span class={TAG_INPUT_TAG_CLASS}>
{tag}
<button
type="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"
onClick={(e) => {
e.stopPropagation();
removeTag(index());
}}
title={`Remove ${tag}`}
class={TAG_INPUT_REMOVE_BUTTON_CLASS}
onClick={(event) => state.handleRemoveTag(event, index())}
title={getTagInputRemoveTitle(tag)}
>
<X class="w-3 h-3" />
</button>
@ -67,13 +30,14 @@ export function TagInput(props: TagInputProps) {
)}
</For>
<input
ref={state.setInputRef}
type="text"
value={inputValue()}
onInput={(e) => 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}
/>
</div>
);

View file

@ -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}`;
}

View file

@ -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;
},
};
}

View file

@ -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',
);