Inline editing for Frost UI with native inputs, validation, asynchronous saving, and custom input callbacks.
- Native text, number, textarea, and single/multiple select inputs
- Filled and outline Frost UI v4 input styles
- Inline buttons, buttons below the input, or automatic saving on change
- Synchronous and asynchronous validation, saving, and display labels
- Cancellable pending requests and stale-result protection
- Keyboard activation, focus restoration, and accessible error feedback
- Custom input hooks with explicit control ownership and cleanup
- Native
Editableclass andeditablefQuery plugin - Existing-instance reuse with frozen resolved options
- System-aware light and dark themes with RTL layouts
- Prebuilt ESM and UMD bundles with source maps
- No component-specific CSS or Sass
- JSDoc-powered IntelliSense
Explore the demo for interactive examples.
npm i @fr0st/ui-editableFrost UI Editable's package entry point is ESM-only and requires a browser DOM. Import the default Editable export and the stylesheets in browser projects and bundlers.
import '@fr0st/ui/dist/frost-ui.min.css';
import Editable from '@fr0st/ui-editable';@fr0st/ui (^4.0.2) and @fr0st/query (^5.0.0) are peer dependencies so the component shares the application's instances.
The ESM bundle imports @fr0st/ui and @fr0st/query. fQuery also imports @fr0st/core, so map all three dependencies when loading the bundle directly in a browser:
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.min.css">
<script type="importmap">
{
"imports": {
"@fr0st/core": "https://cdn.jsdelivr.net/npm/@fr0st/core@latest/dist/frost-core.esm.min.js",
"@fr0st/query": "https://cdn.jsdelivr.net/npm/@fr0st/query@latest/dist/fquery.esm.min.js",
"@fr0st/ui": "https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.esm.min.js"
}
}
</script>
<script type="module">
import Editable from 'https://cdn.jsdelivr.net/npm/@fr0st/ui-editable@latest/dist/frost-ui-editable.esm.min.js';
</script>Load the bundles from your own copy or a CDN:
<link
rel="stylesheet"
href="/path/to/dist/frost-ui.min.css">
<script src="/path/to/dist/frost-ui-bundle.min.js"></script>
<script src="/path/to/dist/frost-ui-editable.min.js"></script>
<!-- or -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui-bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fr0st/ui-editable@latest/dist/frost-ui-editable.min.js"></script>
<script>
const { Editable } = globalThis.UI;
</script>The UMD bundle adds Editable to the existing globalThis.UI object. Load Frost UI's all-in-one bundle first; it supplies the UI and fQuery globals.
The package root resolves to the prebuilt ESM bundle. Published files under dist/ and src/ are also available through matching package subpaths.
Start with a labeled <span>. Editable inserts an editor beside the span while editing is open, then restores the display when saving or canceling:
<h2 id="name-label">Display name</h2>
<span id="display-name" aria-labelledby="name-label">Ada Lovelace</span>import Editable from '@fr0st/ui-editable';
const editable = Editable.init(document.querySelector('#display-name'), {
validate: (value) => value.trim() ? '' : 'A name is required.',
saveValue: (value) => console.log('Saved name:', value),
});Click the value, or focus it and press Enter or Space. Enter saves a non-textarea input; Escape cancels. Set type: 'textarea' for multiline input, type: 'select' with data for native options, and buttons: false to save on change. Supply saveValue to persist changes in the application.
Options are resolved in this order:
Editable.defaults- Serializable
data-ui-*attributes on the host - Options passed to
Editable.init()
Nested plain objects merge, arrays are cloned, and resolved instance.options are shallow-frozen. Set Editable.defaults and Editable.classes before creating instances.
| Option | Type | Default | Description |
|---|---|---|---|
type |
string |
'text' |
Native input type, 'textarea', or 'select'; supported input types are listed below. |
value |
any | null |
Initial committed value. null reads the host's initial text. Arrays support multiple selections. |
emptyText |
string |
'Empty' |
Text for an empty value/label. |
separator |
string |
', ' |
Separator between selected option labels. |
data |
{ value, text }[] or null |
null |
Native select options; values may be strings, numbers, or booleans; labels are text. |
inputAttributes |
plain object | {} |
Generated input attributes, such as min, required, multiple, rows, or aria-label. |
inputClass |
string or null |
null |
Additional generated input classes. |
inputStyle |
'filled' or 'outline' |
'filled' |
Frost UI input style. |
borderStyle |
string or null |
'dotted' |
Editable host's bottom-border style; null disables this override. |
buttons |
boolean or 'bottom' |
true |
Inline buttons, save-on-change without buttons, or buttons below the input. |
lang |
plain object | See below | Text for title, input fallback name, actions, and pending status. |
initInput |
function or null |
null |
Synchronous custom-control setup and optional ownership transfer. |
getValue |
function or null |
Native input reader | Synchronously read a custom submitted value. null uses the default reader. |
setValue |
function or null |
Native input writer | Synchronously restore the committed value into the input. null uses the default writer. |
getLabel |
function or null |
null |
Resolve a text display label, synchronously or asynchronously. |
validate |
function | () => '' |
Return an error message or no error, synchronously or asynchronously. |
saveValue |
function | (value) => value |
Complete application persistence; result is ignored, submitted value is committed. |
Supported input types: text, number, email, url, tel, search, password, date, time, datetime-local, month, week, color, and range.
Native input reads return strings (or string arrays for multiple selects), even with type: 'number'. Use getValue for numeric conversion. 0 and false are display values; nullish labels, empty strings, and empty arrays use emptyText. Ordered arrays compare by their string values; equivalent arrays do not save again. Scalars use strict equality.
Callbacks receive the editor as this, matching the other Frost UI components. Use a regular function or method when accessing the editor through this; arrow functions retain their lexical this. The editor is not passed as a separate argument. A cleanup function returned by initInput is also bound to the editor; returned controller methods retain the controller as this.
| Callback | Arguments | Return and timing |
|---|---|---|
initInput |
(input) |
Synchronous setup before initial setValue/getLabel. Return nothing, cleanup(input), or a controller with dispose() and optional hide(). |
getValue |
(input) |
Synchronous submitted value. Read once for each save attempt. |
setValue |
(input, value) |
Synchronous write; return ignored. Runs at initialization, on opening, after committing a saved value, and through the public setValue() method. |
getLabel |
(value, input) |
Label or promise/thenable for one. Labels are rendered as text. Runs on initialization, value commits, public setValue() calls, and state or select-option refreshes. Closing retains the committed label and any pending label request. |
validate |
(value, input) |
Message or promise for a message; a falsy result means valid. Runs after native input validity checks, including for unchanged values. |
saveValue |
(value, input) |
Any value or promise/thenable for completion. Runs after validation only when changed. Return does not replace the submitted value. |
Native constraint failures and exceptions/rejections from value reading, validation, or saving display text errors and leave editing open. Synchronous initialization/setup failures roll back construction. A later label callback exception or rejected label promise preserves the previous host text; a synchronous initial label exception fails initialization. Custom synchronization, popup, and cleanup callbacks must be synchronous and handle their own errors during normal application calls.
A save captures a value, checks validity, validates, then calls saveValue. While validation or saving is pending, Save is disabled, duplicate submissions are ignored, and aria-busy is true. Inputs remain editable, but later edits are not part of that captured save. Cancel and reopen to start another save. Cancel, Escape, disabling, and disposal invalidate local completion work; stale results cannot update the host.
Canceling keeps the committed display label. The input is restored when editing next opens; closing does not call the setValue callback. Format labels from the committed value argument rather than an input that may contain an unsaved draft.
Validation, save, and asynchronous label requests may expose cancel(). Editable calls it when invalidating or superseding the operation; synchronous throws or rejected cancellation results do not block cleanup. Cancellation is best effort and cannot undo an external save that already took effect.
Editable.init(document.querySelector('#display-name'), {
saveValue(value) {
const controller = new AbortController();
const request = fetch('/profile/name', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
signal: controller.signal,
}).then((response) => {
if (!response.ok) throw new Error('Unable to save the name.');
});
request.cancel = () => controller.abort();
return request;
},
});This fetch example needs an application endpoint. All demo saves are local simulations.
Use initInput to enhance the generated native input, getValue and setValue to read and restore a custom value, and getLabel to format its display text. The demo shows SelectMenu, DateTimePicker, and Autocomplete using these callbacks. Applications load any control libraries, stylesheets, and dependencies they use.
A custom hook can use the generated input, create other DOM, and register listeners on external elements with this.listen(). Use $.addEvent() for generated elements whose removal handles listener cleanup. Return a synchronous cleanup function or { dispose(), hide?() } controller to transfer ownership. Editable calls cleanup once on disposal or rollback, including if the hook disposes the instance before returning. The optional hide() closes a custom popup when editing ends. Use getValue, setValue, and getLabel together for a custom value model.
import $ from '@fr0st/query';
import Editable from '@fr0st/ui-editable';
const previews = new WeakMap();
Editable.init($.findOne('#progress'), {
type: 'number',
value: 25,
inputAttributes: { min: 0, max: 100 },
initInput(input) {
const output = $.create('output', {
class: 'form-text d-block',
attributes: { for: $.getAttribute(input, 'id') },
});
$.after($.closest(input, '.input-group'), output);
previews.set(input, output);
$.addEvent(input, 'input.application', () => {
$.setText(output, `${$.getValue(input)}%`);
});
return () => {
$.remove(output);
previews.delete(input);
};
},
getValue: (input) => Number($.getValue(input)),
setValue: (input, value) => {
$.setValue(input, value);
$.setText(previews.get(input), `${$.getValue(input)}%`);
},
getLabel: (value) => `${value}% complete`,
});The example needs a display host such as <span id="progress"></span>. Update dependent UI in setValue as well as on user input so it reflects initialization and restored values. Keep extra help/preview text outside the input group so the filled input's focus line stays at its border. If setup throws before returning cleanup, the hook must undo resources it created. Returning nothing is appropriate only when generated DOM removal or the custom control's normal removal lifecycle handles all resources. Custom no-button controls should dispatch a native change event after user changes. External popups should be referenced by aria-controls from a generated control so focus ownership can be recognized.
Use kebab-case data-ui-* attributes for serializable options. Arrays and objects use JSON. Supply callbacks through JavaScript.
| Attribute | Example |
|---|---|
data-ui-type |
data-ui-type="textarea" |
data-ui-buttons |
data-ui-buttons="bottom" |
data-ui-input-style |
data-ui-input-style="outline" |
data-ui-input-attributes |
data-ui-input-attributes='{"rows":4}' |
data-ui-value |
data-ui-value="Initial notes" |
data-ui-empty-text |
data-ui-empty-text="Add notes" |
<span
id="notes"
data-ui-toggle="editable"
data-ui-type="textarea"
data-ui-buttons="bottom"
data-ui-input-style="outline"
data-ui-input-attributes='{"rows":4}'
aria-label="Notes">Initial notes</span>import $ from '@fr0st/query';
import '@fr0st/ui-editable';
$('[data-ui-toggle="editable"]').editable();Data attributes configure options; they do not initialize Editable by themselves. Initialize through the class or fQuery plugin. Changing an option's data attribute after initialization does not reconfigure the existing instance.
| Method | Returns | Description |
|---|---|---|
Editable.init(node, options?) |
Editable |
Return the existing instance for an element or create one. |
disable() |
void |
Close directly, discard edits, invalidate pending local work, and prevent activation. |
dispose() |
void |
Release owned controls, listeners, observers, generated DOM, and registration. |
enable() |
void |
Enable editing, subject to disabled ancestor fieldsets. |
getValue() |
any | Read the committed value, excluding unsaved input changes. Arrays are returned as copies; disposed instances return null. |
hide() |
void |
Close without saving, subject to the cancellable hide event. |
setValue(value) |
void |
Replace the committed value and current draft, cancel pending validation/saving, clear errors, and refresh the label. null clears the value. |
show() |
void |
Restore the committed input value and open/focus the editor, if enabled. |
toggle() |
void |
Open or close editing. |
editable.show();
editable.hide();
editable.toggle();
editable.disable();
editable.enable();
editable.setValue('Updated');
const value = editable.getValue();
editable.dispose();show(), hide(), toggle(), enable(), and disable() return no value, matching the other Frost UI components. Public setValue() works while enabled or disabled, leaves the editor open if already open, and does not validate, persist, or emit saved. Array values are copied on assignment. The getValue and setValue options customize input synchronization; the public methods read and replace the committed value.
Calling Editable.init() again for the same element returns its existing instance. Options passed during reuse are ignored. Dispose the current instance before reinitializing with different options.
An instance exposes its original element as instance.node and its shallow-frozen resolved configuration as instance.options. Both remain available during cleanup and become null when disposal finishes.
dispose() cancels pending local operations, releases owned controls before removing their markup, restores the host, and finally removes the registered instance. Cleanup callbacks should release their own resources; let disposal finish before reinitializing the host. Repeated disposal after completion is safe and does not affect a new instance initialized on the same host. Mutating public methods on a disposed instance do nothing and retain their documented return values. Frost Query's host-removal lifecycle also disposes the editor.
Disposal restores owned host attributes, decoration classes, border style, and display state while preserving unrelated application classes/styles. If initialization fails, resources created by the editor and registered state are released before the original error is rethrown. The host can then be initialized again.
Input synchronization does not emit saved or trigger no-button saving. No-button mode saves on native change events. Custom controls must dispatch a native change after user changes if their own events do not reach that listener. Callbacks and event listeners may disable or dispose the editor; subsequent work rechecks instance state.
Events are namespaced fQuery events on the display host, with no additional value payload. Observe the proposed value through callbacks.
| Event | Cancellable | When |
|---|---|---|
show.ui.editable |
Yes | Before opening and input synchronization. |
shown.ui.editable |
No | After opening and focusing the input. |
saved.ui.editable |
No | After committing a changed value and starting its label refresh, before closing. An asynchronous label may still be pending. |
hide.ui.editable |
Yes | Before ordinary close, including after save or unchanged submission. |
hidden.ui.editable |
No | After an ordinary close completes. |
import $ from '@fr0st/query';
$.addEvent('#display-name', 'show.ui.editable shown.ui.editable saved.ui.editable hide.ui.editable hidden.ui.editable', (event) => {
console.log(`${event.type}.ui.editable`);
});
// Prevent opening when the application does not allow edits.
$.addEvent('#display-name', 'show.ui.editable', (event) => {
if (!canEditName()) {
event.preventDefault();
}
});canEditName() in this example is an application-defined permission check.
A normal successful session emits show → shown → saved → hide → hidden. Canceling and unchanged submissions omit saved; unchanged submissions also skip saveValue. Failures stay open without saved. Preventing show keeps the editor closed; preventing hide keeps the session and pending operation active. Disabling, disposing, or disabling an ancestor fieldset closes directly without the ordinary hide events. Repeated show/hide calls do not duplicate completed events. Callbacks and event listeners may disable or dispose the instance; subsequent work rechecks its state.
Importing the package registers the editable fQuery plugin:
import $ from '@fr0st/query';
import '@fr0st/ui-editable';
const editable = $('#display-name').editable({ inputStyle: 'outline' });
$('#display-name').editable('setValue', 'Grace Hopper');
const value = $('#display-name').editable('getValue');
$('#display-name').editable('show');
$('#display-name').editable('hide');
$('#display-name').editable('disable');
$('#display-name').editable('enable');
$('#display-name').editable('dispose');A call applies to every matched element and returns the first instance or the first dispatched method's result. It does not return a QuerySet for chaining. Use one Query instance and one Editable core build so component registration is shared.
- The span receives
role="button", defaulttabindex="0", and Enter/Space activation. Authored tabindex is preserved while enabled. Keyboard focus uses Frost UI's themedfocus-ringstyling. - Disabled editors leave the tab order and expose
aria-disabled. Ancestor fieldsets prevent opening, with the native first-legend exemption. Callingenable()does not override a disabled fieldset. - Hosts expose
aria-controls,aria-expanded, andaria-busy. Pending operations have a status named bylang.saving. - Generated inputs keep explicit
inputAttributes['aria-label']or['aria-labelledby']; otherwise they inherit the host's name or uselang.input. Host descriptions are inherited unless overridden. - Errors are text-only alerts connected through
aria-errormessage,aria-describedby, andaria-invalid. Clearing an error restores authored input ARIA state. - Escape cancels editing unless an enhanced control consumes it to close its popup first. Textarea Enter inserts a newline.
- Closing restores host focus when focus belonged to the editor or its referenced popup. Focus moved outside by the application is preserved.
Applications remain responsible for meaningful field names, instructions, and validation messages. Custom external popups should be referenced through aria-controls from a generated control so their focus ownership can be recognized.
Editable requires an HTML <span> whose parent can contain the generated <div> editor group. In tables, place the span inside a cell. Use a class such as d-block when the display needs block layout. Resolved labels replace the span's child markup with text.
The generated editor is a <div role="group"> with type="button" Save/Cancel actions, so it can be used inside an existing form. Save or Enter validates only the generated input and runs Editable's callbacks without submitting or validating unrelated fields in the outer form.
A display host is not a submitted form field. Synchronize an application-owned hidden field through saveValue when the form needs the committed value:
<form>
<input type="hidden" id="team-value" name="team" value="Research">
<span id="team" aria-label="Team">Research</span>
<button type="submit">Submit team</button>
</form>Editable.init(document.querySelector('#team'), {
saveValue(value) {
document.querySelector('#team-value').value = value;
},
});The initialization group briefly sits hidden beside the host so enhanced controls can capture form association. It is detached while closed; named generated inputs participate in the outer form only while editing is open. Dispose and reinitialize if the form association changes. Form reset does not replace Editable's committed value automatically.
Editable uses Frost UI utilities and does not ship a separate stylesheet. Change generated classes before creating instances:
Editable.classes.saveButton = 'btn btn-success ripple';
Editable.classes.cancelButton = 'btn btn-outline-secondary ripple';| Key | Default | Applied to |
|---|---|---|
buttonContainer |
'd-flex gap-1 mt-1' |
Bottom action row. |
cancelButton |
'btn btn-light ripple' |
Cancel button. |
editable |
'link-primary border-bottom border-primary focus-ring' |
Nonempty editable host. |
empty |
'link-danger fst-italic border-bottom border-danger focus-ring focus-ring-danger' |
Empty editable host. |
error |
'invalid-feedback' |
Error message. |
formError |
'form-error' |
Invalid editor group. |
inputContainer |
'form-input' |
Native input wrapper. |
inputFilled |
'input-filled' |
Filled input. |
inputGroupFilled |
'input-group input-group-filled' |
Filled input/action group. |
inputGroupOutline |
'input-group' |
Outline input/action group. |
inputOutline |
'input-outline' |
Outline input. |
inputRipple |
'ripple-line' |
Filled input focus line. |
saveButton |
'btn btn-primary ripple' |
Save button. |
spinner |
'spinner-border spinner-border-sm text-primary' |
Pending-operation status. |
Set lang per instance or update Editable.defaults.lang before creating instances:
Editable.init(document.querySelector('#display-name'), {
lang: {
title: 'Edit name',
input: 'Display name',
save: 'Apply',
cancel: 'Discard',
saving: 'Saving name',
},
});lang key |
Default |
|---|---|
title |
'Click to edit' |
input |
'Value' |
save |
'Save' |
cancel |
'Cancel' |
saving |
'Saving' |
Frost UI follows the user's preferred color scheme by default. Set data-ui-theme="light" or data-ui-theme="dark" on the document or an ancestor to select a theme explicitly.
Editable uses Frost UI's form, button, border, typography, and spinner utilities. Load the UI stylesheet; no separate Editable CSS is needed. Enhanced controls require their own stylesheets. A document-level theme also covers popups appended to the body.
Direction is inherited from the document or the host's containing region:
<section data-ui-theme="dark" dir="rtl">
<span id="rtl-name" aria-label="اسم العرض">مرحبا بالعالم</span>
</section>Translate the lang strings and configure enhanced-control locales separately. The demo includes theme/direction selectors and an Arabic example.
Install dependencies with npm ci, then install Playwright browsers with npx playwright install --with-deps.
npm test
npm run lint
npm run buildnpm test rebuilds the bundles, then runs the Playwright suite in Chromium, Firefox, and WebKit. npm run test:browser runs the suite against the existing bundles, so rebuild after changing source files.
After building, npm run test:coverage runs Chromium tests and writes coverage reports to coverage/.
npm run test:headed and npm run test:ui also use the existing bundles and open headed browsers or the Playwright UI.
To view the demo, open demo/index.html in your browser after building.
Frost UI Editable is released under the MIT License.