Webdev Token Input
Build multi-value token/chip inputs (tags, filters, email recipients) on top of combobox autocomplete patterns.
Prerequisites
This skill builds on webdev-combobox-autocomplete. Start there for state model, ARIA patterns, keyboard navigation, and async suggestions.
Token State Model
Extends combobox state with token-specific properties:
interface TokenInputState extends ComboboxState {
// Token-specific state
tokens: FilterToken[];
activeTokenIndex: number | null; // For arrow-key navigation between tokens
}
interface FilterToken {
id: string;
key?: string; // e.g., "status", "assignee"
operator?: string; // e.g., ":", "=", "!="
value: string; // e.g., "completed", "john@example.com"
display: string; // Formatted for visual pill
}
Production Patterns Analysis
Analyzing Datadog, Grafana, Honeycomb, Linear, and GitHub reveals two dominant paradigms:
Text-Based (Datadog, Kibana, Sentry, GitHub)
Colon as delimiter—typing status: triggers value suggestions, and completing the value creates a visual token.
Benefits: Power users can edit any part of a query inline, syntax highlighting, fast keyboard-only flow.
Example: Datadog's "search pills" with syntax highlighting enable clicking anywhere in service:payment AND status:error to modify it.
Visual Builders (Grafana, Honeycomb, Linear)
Dropdowns for each component, tokens displayed as discrete clickable pills.
Benefits: Reduces errors, clear structure, good for non-technical users.
Example: Linear's approach treats each filter as a structured object where clicking the operator (is) toggles through options (is not, is any of).
The UX differences are significant. For complex observability queries, the text-based approach wins; for task management, the visual builder reduces errors. Start with text-based for observability/search; visual for task management.
Token Creation Triggers
Pattern 1: Colon Delimiter
function handleChange(value) {
if (value.endsWith(':')) {
const key = value.slice(0, -1);
setState({
contextKey: key,
suggestions: getValuesForKey(key),
status: 'suggesting'
});
}
}
Pattern 2: Enter Key
function handleKeyDown(e) {
if (e.key === 'Enter' && highlightedIndex >= 0) {
createToken(suggestions[highlightedIndex]);
setState({ inputValue: '', highlightedIndex: -1, isOpen: false });
}
}
Pattern 3: Comma/Space (Simple Tags)
function handleChange(value) {
if (value.endsWith(',') || value.endsWith(' ')) {
const token = value.slice(0, -1).trim();
if (token) createToken({ value: token });
setState({ inputValue: '' });
}
}
Backspace Behavior
Backspace requires two-step deletion for safety: first backspace selects/highlights the last token, second backspace deletes it. Bootstrap Tokenfield pioneered this pattern. Single-backspace deletion is faster but causes accidental deletions.
function handleKeyDown(e) {
if (e.key === 'Backspace' && inputValue === '') {
if (activeTokenIndex === null) {
// First backspace: highlight last token
setActiveTokenIndex(tokens.length - 1);
} else {
// Second backspace: delete highlighted token
deleteToken(activeTokenIndex);
setActiveTokenIndex(null);
}
}
}
Implement the safer two-step pattern and make it configurable if needed.
Arrow Navigation Between Tokens
Arrow navigation follows a clear hierarchy. Up/Down move through suggestions using virtual focus. Left/Right navigate between tokens—but only when the input is empty. This conditional is critical: if there's text in the input, arrows control cursor position within that text. Downshift's useMultipleSelection implements this cleanly by checking inputValue.length === 0 before enabling token navigation.
function handleKeyDown(e) {
if (inputValue.length === 0) {
if (e.key === 'ArrowLeft') {
setActiveTokenIndex(prev =>
prev === null ? tokens.length - 1 : Math.max(0, prev - 1)
);
}
if (e.key === 'ArrowRight') {
setActiveTokenIndex(prev =>
prev === null ? 0 : Math.min(tokens.length - 1, prev + 1)
);
}
}
}
Token Editing Patterns
Token editing patterns fall into four categories:
Pattern 1: Delete-and-Retype (Simplest)
Clicking a token removes it and places its text in the input. Most forgiving of edge cases.
function handleTokenClick(token) {
deleteToken(token.id);
setState({ inputValue: token.display });
}
Pattern 2: Inline Text Editing (Datadog)
The token expands into editable text inline.
function handleTokenClick(token) {
setEditingTokenId(token.id);
// Replace token with contenteditable or input
}
Pattern 3: Popover Editing (Linear)
Clicking opens a structured editor for operator and value.
function handleTokenClick(token) {
showPopover({
key: token.key,
operator: token.operator,
value: token.value,
position: getTokenRect(token.id)
});
}
Pattern 4: No Editing (Most Common)
Tokens can only be deleted and recreated. Safest for preventing invalid states.
For your implementation, start with delete-and-retype—it's the most forgiving of edge cases.
Context-Dependent Suggestions
Different values for different keys:
function getSuggestionsForContext(contextKey, query) {
const cacheKey = `${contextKey}:${query}`;
if (cache[cacheKey]) return cache[cacheKey];
const suggestions = contextKey === 'status'
? ['open', 'closed', 'pending']
: contextKey === 'assignee'
? fetchUsers(query)
: fetchGeneric(contextKey, query);
cache[cacheKey] = suggestions;
return suggestions;
}
Maintain separate caches keyed by field name to prevent redundant fetches.
Key:Value Parsing
Extract structured data from text input:
function parseTokenInput(input) {
// Simple pattern: "key:value"
const colonMatch = input.match(/^([^:]+):(.+)$/);
if (colonMatch) {
return {
key: colonMatch[1].trim(),
operator: ':',
value: colonMatch[2].trim(),
display: input
};
}
// Fallback: plain value
return {
value: input.trim(),
display: input.trim()
};
}
For complex operators, see webdev-filter-query-builder skill.
Multi-Select ARIA
Extend combobox ARIA with multi-select:
<div role="group" aria-label="Selected filters">
<div role="button" tabindex="0" aria-label="Filter: status is error">
status:error
</div>
</div>
<input role="combobox" aria-multiselectable="true" />
<ul role="listbox" aria-multiselectable="true">
<!-- suggestions -->
</ul>
<div role="status" aria-live="polite" aria-atomic="true">
Filter 'status:error' added. 3 filters applied.
</div>
Live region: Announce token creation/deletion: "Filter added. 3 filters applied."
Multiple Selection Hook
Downshift provides useMultipleSelection:
const {
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems
} = useMultipleSelection({
selectedItems: tokens,
onSelectedItemsChange: ({ selectedItems }) => setTokens(selectedItems)
});
const {
getInputProps,
getMenuProps,
getItemProps,
highlightedIndex
} = useCombobox({
items: suggestions,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
addSelectedItem(selectedItem);
setInputValue('');
}
}
});
Implementation Checklist
- ✓ Implement base combobox (webdev-combobox-autocomplete)
- ✓ Add token state array and activeTokenIndex
- ✓ Implement token creation trigger (colon/Enter/comma)
- ✓ Add two-step backspace deletion
- ✓ Conditional arrow navigation (only when input empty)
- ✓ Choose token editing pattern (recommend delete-and-retype)
- ✓ Context-dependent suggestions with separate caches
- ✓ Key:value parsing for structured tokens
- ✓ Multi-select ARIA attributes
- ✓ Live region announcements
Common Use Cases
- Filter bars: Observability tools (Datadog, Grafana)
- Tag inputs: Content management, categorization
- Email recipients: "To" and "CC" fields
- Multi-select: Assign multiple users, categories, labels
- Search chips: E-commerce faceted search
For domain-specific filter queries with operators and AST, see webdev-filter-query-builder.