Chip Input
Collects several values into removable tokens — the markup and styling only; the behaviour is yours to write.
When to Use
components.js contains nothing for chips. The helper renders a labelled field, an add button, and an empty list container — adding, removing, validating, and serialising values for submit are all yours to implement. Budget for that, and see Accessibility for what your handlers must do.
Variants
With a label and add text
Giving the add button text as well as an icon is the accessible default — an icon-only button here has no accessible name.
<?= ws_chip_input('attendees', [
'label' => 'Attendees',
'placeholder' => 'Enter email address',
'type' => 'email',
'addIcon' => 'person_add',
'addText' => 'Add',
]) ?>Tag input, no label
Omitting the label is only safe when a heading beside the field already names it. Otherwise the input has nothing to announce.
<?= ws_chip_input('tags', ['placeholder' => 'Add tag…', 'addText' => 'Add']) ?>Chip markup
Your JavaScript renders chips into the __list container. This is the structure the CSS expects — note the aria-label on the remove button, which names which chip it removes.
<div class="ws-chip">
<span>alice@example.com</span>
<button class="ws-chip__remove" type="button"
aria-label="Remove alice@example.com">
<span class="material-symbols-outlined">close</span>
</button>
</div>States
| State | Behavior |
|---|---|
| Empty | As rendered: an empty .ws-chip-input__list and a field showing the placeholder. |
| With chips | Entirely your code's doing. Nothing populates the list server-side, so pre-existing values need rendering into it on page load. |
| Hover | The field border moves from --border-default to --border-hover; the add button surface shifts to --surface-hover. |
| Focus | The inner input is a standard .ws-input, so it inherits Input's focus ring. |
| Invalid entry | No validation is provided. With type: 'email' the input gets native email semantics, but nothing checks a value before it becomes a chip — validate in your add handler. |
| Duplicate entry | Not prevented. Guard against it yourself, or the same email can be added repeatedly. |
| Disabled | Not supported. There's no disabled option on the helper. |
Real-World Usage
Inviting participants to a session. The sketch below covers what the component leaves to you: Enter-to-add, validation, deduplication, an accessible remove button, and a hidden field so the values actually submit.
const field = document.getElementById('invitees');
const list = document.getElementById('invitees-list');
const store = document.getElementById('invitees-value'); // your hidden input
const emails = [];
function addEmail() {
const value = field.value.trim();
if (!value || !field.checkValidity()) return; // validate
if (emails.includes(value)) { field.value = ''; return; } // dedupe
emails.push(value);
store.value = emails.join(','); // so the form submits it
const chip = document.createElement('div');
chip.className = 'ws-chip';
chip.innerHTML = `<span>${escapeHtml(value)}</span>`;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'ws-chip__remove';
remove.setAttribute('aria-label', `Remove ${value}`); // names the chip
remove.innerHTML = '<span class="material-symbols-outlined">close</span>';
remove.addEventListener('click', () => {
emails.splice(emails.indexOf(value), 1);
store.value = emails.join(',');
chip.remove();
field.focus(); // never strand focus on a removed element
});
chip.appendChild(remove);
list.appendChild(chip);
field.value = '';
field.focus();
}
// Enter should add — users will expect it, and it isn't wired for you.
field.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addEmail(); }
});Options
| Option | Type | Default | Purpose |
|---|---|---|---|
$name | string | '' | Positional. Field name and the ID prefix — the list container becomes {name}-list. |
label | string | null | Label text |
placeholder | string | 'Add item...' | Input placeholder |
type | string | 'text' | email | text. Sets the input type, giving email fields the right keyboard on mobile. |
addIcon | string | 'add' | Icon on the add button |
addText | string | null | Text on the add button. Without it the button is icon-only and falls back to an aria-label derived from label. |
removeIcon | string | 'close' | Intended for the chip remove button. Since chips are yours to render, this is advisory. |
id | string | $name | Element ID |
class | string | '' | Additional CSS classes |
attrs | array | [] | Extra HTML attributes |
Accessibility
Because the behaviour is yours, so is most of the accessibility. The list below separates what the component gives you from what your code has to add.
| Concern | Behavior |
|---|---|
| Label binding | Correctly wired: the template emits <label for="{id}-input"> bound to the field. Passing label gives the input a proper accessible name — unlike Form Row, which doesn't bind. Omit label and the field is unnamed, so supply one or pass attrs => ['aria-label' => …]. |
| Add button | A real <button type="button">, so it won't accidentally submit the form. With addText it's named by its visible text; without, it falls back to aria-label="Add {label}" so an icon-only button still has a name. |
| Enter to add | Not implemented. Users will press Enter; wire it, and call preventDefault() so it doesn't submit the form. |
| Remove buttons | Yours to render. Give each an aria-label naming the value — "Remove alice@example.com". A column of buttons all labelled "Remove" is unusable by screen reader. |
| Focus after removal | Removing a chip destroys the focused element and drops focus to <body>. Move focus back to the input, as in the example above. |
| Announcing changes | Chips appear and disappear silently. Consider a visually-hidden aria-live="polite" region announcing "alice@example.com added" so the action is confirmed non-visually. |
| Submitting values | Chips are <div>s, not form controls — they submit nothing. Maintain a hidden input, or the user's carefully built list is lost on submit. |
Tokens
| Token | Used for |
|---|---|
--surface-default / --surface-hover | Chip and add-button backgrounds |
--border-default / --border-hover | Field and chip borders |
--color-ink | Chip and label text |
--radius-sm | Chip corners |
--space-2 | Gap between chips and internal padding |
--text-small / --text-h4 | Chip text and label type scale |
CSS Classes
| Class | Purpose |
|---|---|
.ws-chip-input__label | Field label, bound to the input via for="{id}-input" |
.ws-chip-input__field | Row holding the input and add button |
.ws-chip-input__add | Add button |
.ws-chip-input__list | Container your chips render into, id="{name}-list" |
.ws-chip | An individual chip. Styled here, rendered by you. |
.ws-chip__remove | Chip remove button |
The inner input also carries .ws-input, so it inherits every Input state and token.
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_chip_input() helper function |
includes/components/chip-input.php | Template — label, field, add button, empty list |
includes/components/components.css | Styles (.ws-chip and .ws-chip-input rules) |
There is deliberately no JS file in this list — the interaction layer doesn't exist yet.