mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-14 16:40:30 +00:00
Split tag input runtime owners
This commit is contained in:
parent
90568bfeb8
commit
6f2bb85373
7 changed files with 240 additions and 58 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
60
frontend-modern/src/components/shared/TagInput.test.tsx
Normal file
60
frontend-modern/src/components/shared/TagInput.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
40
frontend-modern/src/components/shared/tagInputModel.ts
Normal file
40
frontend-modern/src/components/shared/tagInputModel.ts
Normal 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}`;
|
||||
}
|
||||
66
frontend-modern/src/components/shared/useTagInputState.ts
Normal file
66
frontend-modern/src/components/shared/useTagInputState.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue