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