Input Addons
Icons and text joined to a field, sharing its border, focus ring and error colours
Overview
An addon is an icon or a little text joined to the start or end of a field - a magnifier on a search box, "£" on a price, "kg" on a weight. The addon and the field share one border and one rounded outline, so they read as a single control: the focus ring goes round the whole group, and the addon takes the focus and error colours along with the field. Each addon is as wide as what it holds, so "£" and "https://" both fit.
In a Forma blueprint set formConfig.prefix and/or formConfig.suffix. It works on single-line inputs, selects and textareas. For an input that is already on the page, use E.inputGroup() - it writes exactly the same markup.
What a slot can be
| Value | Renders | Where |
|---|---|---|
{icon: 'search'} |
A Domma icon (a plain icon name only) | Forma and E.inputGroup |
{text: '£'} |
Text, set as text - never parsed as HTML | Forma and E.inputGroup |
'kg' |
Shorthand for {text: 'kg'} |
Forma and E.inputGroup |
{html: '<kbd>K</kbd>'} |
Raw HTML - you are responsible for what goes in | Forma only |
Addons are decoration: clicks pass straight through to the field, and they are hidden from screen readers, so the field's label should still say what it is for.
Interactive extras
Four extras are real controls joined to the field in the same style - buttons you can click and tab to, each with an accessible name. The keys are the same in a blueprint's formConfig and in E.inputGroup()'s options, and they sit happily inside a prefix or suffix.
| Key | Adds | Applies to |
|---|---|---|
reveal: true |
An eye button that shows and hides the password | Password inputs |
clear: true |
An × that appears once there is a value and empties the field | Text-like inputs and textareas |
stepper: true |
− and + buttons that honour min, max and step |
Number inputs |
counter: true | 200 |
"12 / 200" under the field, updated as you type | Text-like inputs and textareas |
Icon prefix - search field
<div id="form-icon-prefix" class="mb-4"></div>/**
* Icon prefix - search field - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
search: {
type: 'text',
label: 'Search',
formConfig: {prefix: {icon: 'search'}, placeholder: 'Type to search...'}
}
}, '#form-icon-prefix');
}search: {
type: 'text',
label: 'Search',
formConfig: {prefix: {icon: 'search'}, placeholder: 'Type to search...'}
}
Text prefix and suffix - price field
<div id="form-text-addons" class="mb-4"></div>/**
* Text prefix and suffix - price field - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
price: {
type: 'number',
label: 'Price',
formConfig: {prefix: {text: '£'}, suffix: {text: '.00'}, placeholder: '0'}
}
}, '#form-text-addons');
}price: {
type: 'number',
label: 'Price',
formConfig: {
prefix: {text: '£'},
suffix: {text: '.00'}
}
}
Units and schemes
The addon grows to fit its text, so a unit or a URL scheme sits beside the value without covering it.
<div id="form-units" class="mb-4"></div>/**
* Units and schemes - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
weight: {
type: 'number',
label: 'Parcel weight',
formConfig: {suffix: {text: 'kg'}, placeholder: '0.0'}
},
website: {
type: 'string',
label: 'Website',
formConfig: {prefix: {text: 'https://'}, placeholder: 'example.com'}
}
}, '#form-units');
}weight: {
type: 'number',
label: 'Parcel weight',
formConfig: {suffix: {text: 'kg'}}
},
website: {
type: 'string',
label: 'Website',
formConfig: {prefix: {text: 'https://'}, placeholder: 'example.com'}
}
String shorthand
A plain string is shorthand for {text: ...}.
<div id="form-shorthand" class="mb-4"></div>/**
* String shorthand - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
salary: {
type: 'number',
label: 'Salary',
formConfig: {prefix: '£', suffix: 'a year', placeholder: '32000'}
}
}, '#form-shorthand');
}salary: {
type: 'number',
label: 'Salary',
formConfig: {prefix: '£', suffix: 'a year'}
}
A select with an icon prefix
<div id="form-select" class="mb-4"></div>/**
* A select with an icon prefix - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
country: {
type: 'select',
label: 'Country',
options: [
{value: 'gb', label: 'United Kingdom'},
{value: 'ie', label: 'Ireland'},
{value: 'fr', label: 'France'}
],
formConfig: {prefix: {icon: 'globe'}}
}
}, '#form-select');
}country: {
type: 'select',
label: 'Country',
options: [
{value: 'gb', label: 'United Kingdom'},
{value: 'ie', label: 'Ireland'},
{value: 'fr', label: 'France'}
],
formConfig: {prefix: {icon: 'globe'}}
}
A textarea with a suffix
<div id="form-textarea" class="mb-4"></div>/**
* A textarea with a suffix - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
message: {
type: 'textarea',
label: 'Message',
formConfig: {rows: 4, suffix: {icon: 'message-square'}, placeholder: 'Write your message...'}
}
}, '#form-textarea');
}message: {
type: 'textarea',
label: 'Message',
formConfig: {rows: 4, suffix: {icon: 'message-square'}}
}
HTML suffix - keyboard hint
<div id="form-html-suffix" class="mb-4"></div>/**
* HTML suffix - keyboard hint - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
query: {
type: 'text',
label: 'Quick search',
formConfig: {prefix: {icon: 'search'}, suffix: {html: '<kbd>Ctrl K</kbd>'}, placeholder: 'Search anywhere...'}
}
}, '#form-html-suffix');
}query: {
type: 'text',
label: 'Quick search',
formConfig: {
prefix: {icon: 'search'},
suffix: {html: '<kbd>Ctrl K</kbd>'}
}
}
Icon prefix and icon suffix
<div id="form-mixed" class="mb-4"></div>/**
* Icon prefix and icon suffix - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
email: {
type: 'email',
label: 'Email',
formConfig: {prefix: {icon: 'mail'}, suffix: {icon: 'check-circle'}, placeholder: 'you@example.com'}
}
}, '#form-mixed');
}email: {
type: 'email',
label: 'Email',
formConfig: {
prefix: {icon: 'mail'},
suffix: {icon: 'check-circle'}
}
}
E.inputGroup - on an input already on the page
The field below is a plain <input class="form-input"> in this page's HTML. E.inputGroup() wraps it and joins the addons. The buttons change them with update(), take them away with destroy(), and bind the input again.
<label class="form-label" for="ig-demo">Budget</label>
<input id="ig-demo" class="form-input" type="number" placeholder="0">
<button id="ig-text" class="btn btn-outline btn-sm" type="button">Prefix "£"</button>
<button id="ig-icon" class="btn btn-outline btn-sm" type="button">
<span data-icon="tag"></span> Icon prefix
</button>
<button id="ig-kg" class="btn btn-outline btn-sm" type="button">Suffix "kg"</button>
<button id="ig-no-suffix" class="btn btn-outline btn-sm" type="button">No suffix</button>
<button id="ig-destroy" class="btn btn-outline btn-sm" type="button">
<span data-icon="x"></span> destroy()
</button>
<button id="ig-bind" class="btn btn-primary btn-sm" type="button" disabled="">Bind again</button>/**
* E.inputGroup - on an input already on the page - from public/showcase/forms/input-addons.
*/
export function mount(root) {
// ---- E.inputGroup on a hand-written input --------------------------------
let group = null;
const updaters = '#ig-text, #ig-icon, #ig-kg, #ig-no-suffix, #ig-destroy';
const showMarkup = () => {
const el = group ? group.wrapper : $(root).find('#ig-demo').get(0);
// Show icons as the data-icon span they started as, not the SVG they became.
const html = el ? el.outerHTML.replace(/<svg[^>]*data-icon="([^"]+)"[\s\S]*?<\/svg>/g, '<span data-icon="$1"></span>') : '';
$(root).find('#ig-markup').text(html.replace(/></g, '>\n<').replace(/<span data-icon="([^"]+)">\n<\/span>/g, '<span data-icon="$1"></span>'));
};
const bind = () => {
group = E.inputGroup(root.querySelector('#ig-demo'), {prefix: '£', suffix: '.00'});
$(updaters).prop('disabled', false);
$(root).find('#ig-bind').prop('disabled', true);
showMarkup();
};
const change = (slots) => {
if (!group) return;
group.update(slots);
showMarkup();
};
$(root).find('#ig-text').on('click', () => change({prefix: '£'}));
$(root).find('#ig-icon').on('click', () => change({prefix: {icon: 'tag'}}));
$(root).find('#ig-kg').on('click', () => change({suffix: 'kg'}));
$(root).find('#ig-no-suffix').on('click', () => change({suffix: null}));
$(root).find('#ig-destroy').on('click', () => {
if (!group) return;
group.destroy();
group = null;
$(updaters).prop('disabled', true);
$(root).find('#ig-bind').prop('disabled', false);
showMarkup();
});
$(root).find('#ig-bind').on('click', bind);
bind();
}Current markup:
let group = E.inputGroup('#ig-demo', {prefix: '£', suffix: '.00'});
$('#ig-icon').on('click', () => group.update({prefix: {icon: 'tag'}}));
$('#ig-kg').on('click', () => group.update({suffix: 'kg'}));
$('#ig-no-suffix').on('click', () => group.update({suffix: null}));
$('#ig-destroy').on('click', () => group.destroy());
Show / hide password
reveal: true adds an eye button that switches the field between hidden and plain text. It is a toggle button (aria-pressed), so a screen reader hears "Show password, toggle button, pressed". Clicking it leaves the caret in the field.
<div id="form-reveal" class="mb-4"></div>/**
* Show / hide password - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
// ---- Interactive extras ----------------------------------------------------
render({
password: {
type: 'password',
label: 'Password',
formConfig: {prefix: {icon: 'lock'}, reveal: true, placeholder: 'Try typing, then the eye'}
}
}, '#form-reveal');
}password: {
type: 'password',
label: 'Password',
formConfig: {prefix: {icon: 'lock'}, reveal: true}
}
Clear button
clear: true adds an × that shows only while the field has a value. Clicking it empties the field, fires input and change - so the form's model and any listener of your own see the change - and puts the focus back in the field. Here it sits beside an icon prefix; the last value the model saw is shown underneath.
""
<div id="form-clear" class="mb-2"></div>
Model value: <code id="clear-model">""</code>/**
* Clear button - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const clearForm = F.create({
search: {
type: 'string',
label: 'Search',
formConfig: {prefix: {icon: 'search'}, clear: true, placeholder: 'Type, then clear'}
}
}, {search: 'quarterly report'}, {layout: 'stacked', showSubmitButton: false});
clearForm.renderTo('#form-clear');
const showClearModel = () => $(root).find('#clear-model').text(JSON.stringify(clearForm.getData().search ?? ''));
$(root).find('#form-clear').on('change', showClearModel);
showClearModel();
// ---- E.inputGroup on a hand-written input --------------------------------
let group = null;
const change = (slots) => {
if (!group) return;
group.update(slots);
showMarkup();
};
}search: {
type: 'string',
label: 'Search',
formConfig: {prefix: {icon: 'search'}, clear: true, placeholder: 'Type, then clear'}
}
Number stepper
stepper: true puts − and + either side of a number field. They step by the field's step, stop at min and max (the button at a limit is disabled), and fire input and change on every step. Hold a button down to keep stepping. Addons still go outside, as with the "£" and "per night" here.
<div id="form-stepper" class="mb-4"></div>/**
* Number stepper - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
guests: {type: 'integer', label: 'Guests', min: 1, max: 8, default: 2, formConfig: {stepper: true}},
rate: {
type: 'number',
label: 'Nightly rate',
min: 0,
max: 500,
step: 12.5,
default: 100,
formConfig: {prefix: '£', suffix: 'per night', stepper: true}
}
}, '#form-stepper');
}guests: {
type: 'integer',
label: 'Guests',
min: 1,
max: 8,
formConfig: {stepper: true}
},
rate: {
type: 'number',
label: 'Nightly rate',
min: 0,
max: 500,
step: 12.5,
formConfig: {prefix: '£', suffix: 'per night', stepper: true}
}
Character counter
counter: true shows "12 / 200" under the field, using the field's maxLength; a number (counter: 60) sets the limit itself. It updates as you type, is announced politely to screen readers and is linked to the field with aria-describedby. It turns amber from 90% of the limit, and red if an explicit limit is passed.
<div id="form-counter" class="mb-4"></div>/**
* Character counter - from public/showcase/forms/input-addons.
*/
export function mount(root) {
const F = Domma.forms;
const render = (blueprint, target) => F.create(blueprint, {}, {layout: 'stacked', showSubmitButton: false}).renderTo(target);
render({
headline: {type: 'string', label: 'Headline', formConfig: {counter: 60, clear: true, placeholder: 'Up to 60 characters'}},
bio: {type: 'textarea', label: 'Short bio', maxLength: 200, formConfig: {rows: 3, counter: true}}
}, '#form-counter');
}headline: {
type: 'string',
label: 'Headline',
formConfig: {counter: 60, clear: true}
},
bio: {
type: 'textarea',
label: 'Short bio',
maxLength: 200,
formConfig: {rows: 3, counter: true}
}
Extras on inputs already on the page
The same four keys work with E.inputGroup() on hand-written inputs. The fields below are plain HTML; one call each joins the extras.
<div class="form-group">
<label class="form-label" for="x-pw">New password</label>
<input id="x-pw" class="form-input" type="password" autocomplete="new-password">
</div>
<div class="form-group">
<label class="form-label" for="x-q">Filter</label>
<input id="x-q" class="form-input" type="search" value="invoices">
</div>
<div class="form-group">
<label class="form-label" for="x-qty">Quantity</label>
<input id="x-qty" class="form-input" type="number" min="0" max="10" value="2">
</div>
<div class="form-group">
<label class="form-label" for="x-note">Delivery note</label>
<input id="x-note" class="form-input" type="text" maxlength="40">
</div>/**
* Extras on inputs already on the page - from public/showcase/forms/input-addons.
*/
export function mount(root) {
E.inputGroup(root.querySelector('#x-pw'), {reveal: true});
E.inputGroup(root.querySelector('#x-q'), {prefix: {icon: 'filter'}, clear: true});
E.inputGroup(root.querySelector('#x-qty'), {stepper: true, suffix: 'boxes'});
E.inputGroup(root.querySelector('#x-note'), {counter: true, clear: true});
}E.inputGroup('#x-pw', {reveal: true});
E.inputGroup('#x-q', {prefix: {icon: 'filter'}, clear: true});
E.inputGroup('#x-qty', {stepper: true, suffix: 'boxes'});
E.inputGroup('#x-note', {counter: true, clear: true}); // true reads maxlength="40"
Tutorial - joining an addon to your own input
Step 1 - Write the input as usual
No wrapper markup needed. Give the input the usual form-input (or form-select) class and a label.
<label class="form-label" for="weight">Parcel weight</label>
<input id="weight" class="form-input" type="number">
Step 2 - Join the addons
E.inputGroup(selector, {prefix, suffix}) wraps the input in a .input-group-icon group and adds the addons. Each slot takes the same values as formConfig.prefix: an icon, text, or a string.
const weight = E.inputGroup('#weight', {suffix: 'kg'});
Step 3 - Change or remove them later
update() changes either slot and leaves the other alone; null removes one. destroy() removes the addons and, if E.inputGroup made the wrapper, the wrapper too, leaving the input where it was.
$('#units').on('change', (e) => {
weight.update({suffix: e.target.value === 'metric' ? 'kg' : 'lb'});
});
weight.update({prefix: {icon: 'package'}}); // add a prefix, keep the suffix
weight.update({prefix: null}); // and take it off again
weight.destroy(); // back to a plain input
Binding an input that Forma already rendered with addons reuses Forma's group, so E.inputGroup() is also the way to change a blueprint field's addons after it is on screen.
Step 4 - Add the extras
Pass reveal, clear, stepper or counter alongside (or instead of) the addons. An extra the input cannot take is skipped - reveal needs a password, stepper a number - so a shared options object is safe.
const qty = E.inputGroup('#qty', {stepper: true, suffix: 'boxes'});
const note = E.inputGroup('#note', {counter: 120, clear: true});
qty.step(1); // step from code, as the + button does
note.update({clear: false}); // extras come and go through update() too
Step 5 - Set values from code
The extras follow the field's input and change events and a form reset. If you set value from code without firing an event, call refresh() so the clear button, the stepper limits and the counter catch up.
$('#note').val('Leave with the neighbour at number 12');
note.refresh();
In a blueprint, Forma binds the extras after it renders and removes them again on a re-render or form.destroy(); a formConfig.labels object renames the buttons (for another language, say).
Options and methods
| Option / method | Description |
|---|---|
prefix |
Slot at the start: {icon}, {text} or a string. Default null |
suffix |
Slot at the end, same values. Default null |
reveal |
true: show / hide button on a password input. Default false |
clear |
true: clear button, shown while there is a value. Default false |
stepper |
true: − / + buttons on a number input. Default false |
counter |
true (limit from maxLength) or a number. Default false |
labels |
Button names: {reveal: 'Show password', clear: 'Clear', decrease: 'Decrease', increase: 'Increase'} |
update({...}) |
Change any of the above; null removes an addon, false an extra, leaving a key out keeps it. Returns the instance |
refresh() |
Re-read the value after setting it from code: clear button, stepper limits, counter |
step(dir) |
Step a stepper field by one step, 1 up or -1 down |
destroy() |
Remove the addons and extras (and their listeners), and the wrapper if this instance made it |
input / wrapper / extras |
The bound input, its .input-group-icon group, and the extras' elements (reveal, clear, decrease, increase, counter) |