editable-field is a tiny Web Component that wraps an <input>
together with a pencil/save/cancel button set. The pencil button enables
editing, and the save/cancel controls are revealed once the field is in
edit mode. It keeps the host attributes (name, value, disabled) in
sync with the internal input and emits lifecycle events that bubble up to
any listener, including handlers that watch for namespaced event names.
See a live demo and
check the example/ folder for a hands-on playbook.
Install via npm, yarn, or pnpm and the package will register the
<editable-field> custom element globally.
npm i -S @substrate-system/editable-fieldImport the element and its styles into your bundle entry. The JS import
touches customElements.define so the tag is ready to use; the CSS import
brings the default gap, outline, and button styles.
import '@substrate-system/editable-field'
import '@substrate-system/editable-field/css'Now use <editable-field> in your markup.
name(string, default'') Mirrors the attribute to the inner<input>'snameandidvalue(string, default'') Syncs to the inputvalue. Updating the host attribute when the field is idle updates what appears when the next edit session starts.disabled(any truthy value, defaulttrue) Keeps the input disabled and addsaria-disabled="true". Remove the attribute (or set it to'false') to re-enable typing. Example: `<editable-field name="email" value="hi@example.com" disabled`
can-edit(boolean-ish, default off) Hands ownership of the editing state to your app. See Controlled mode. Likedisabled, an absent attribute or the string'false'counts as off.editing(boolean-ish, default off) Whether the editor is open. Setting it caches the current value as the cancel baseline, enables and focuses the input, and adds theeditingclass. Clearing it closes the editor. Setting it does not emitedit, because your app already knows.pending(boolean-ish, default off) Set this while your save is in flight. Adds thependingclass, disables the input and both buttons, and leaves the editor open.editingandpendingcan be cleared in either order.no-trigger(boolean-ish, default off) Hides the built-in pencil button so your app can supply its own affordance. The button stays in the markup but getshidden, which keeps it out of the tab order and the accessibility tree.
edit- Origin: host element via
this.emit('edit')/this.dispatch('edit') - Bubbles: yes
- Notes: Fires when the pencil button is clicked. Uncontrolled, the
editor is already open by the time it fires; controlled, it is a
request and nothing has opened yet.
The event is also available as
editable-field:editthroughfield.on('edit', handler).
- Origin: host element via
save- Origin: internal
<input> - Bubbles: yes
- Notes: Fired when the save button is clicked. Because the event
originates from the
<input>,event.target.valuegives the new value. A namespacededitable-field:saveis emitted viafield.on('save', …)as shown inexample/index.ts. Uncontrolled, the editor has already closed by the time it fires; controlled, it stays open until you clearediting.
- Origin: internal
cancel- Origin: host element (
this.emit('cancel'),this.dispatch('cancel')) - Bubbles: yes
- Notes: Triggered by the cancel button or the Escape key. The component
restores the cached
_originalValuebefore the event bubbles. Controlled, restoring the value is all it does; clearingeditingis yours. Both are inert whilependingis set.
- Origin: host element (
The example/index.ts file also shows how to log the events, inspect the event
type, and listen for the wildcard field.addEventListener('*', handler)
that surfaces every emitted event.
See `./example/index.ts.
import { type EditableField } from '@substrate-system/editable-field'
const field = document.querySelector<EditableField>('editable-field')
field?.addEventListener('save', ev => {
const input = ev.target
console.log('saved value:', input.value)
})
field?.on('save', ev => {
console.log('namespaced event:', ev.type)
})The component consumes the same attributes as the native <input>:
<form>
<label for="email">Email</label>
<editable-field
id="email"
name="email"
value="user@example.com"
></editable-field>
</form>When placed inside a form, the name attribute flows through to the inner
input so form serialization (via FormData) works without extra wiring.
By default the component owns the editing state. The pencil opens the editor, save and cancel close it, and you just listen for events.
Pass can-edit and that ownership flips. The component emits events but
never opens or closes the editor on its own, so a pencil click becomes a
request rather than an action. That is what makes an async save
representable: save fires, your handler runs, and the editor stays open
until you clear editing.
const field = document.querySelector('editable-field')
field.addEventListener('edit', () => {
field.setAttribute('editing', '')
})
field.addEventListener('cancel', () => {
field.removeAttribute('editing')
})
field.addEventListener('save', async ev => {
const next = ev.target.value
field.setAttribute('pending', '')
try {
await saveToServer(next)
field.setAttribute('value', next)
field.removeAttribute('editing')
} catch (err) {
// leave `editing` set so the user can correct and retry
showError(err)
} finally {
field.removeAttribute('pending')
}
})While pending is set the input and both buttons are disabled, and
Escape does nothing, so a slow save cannot be double-submitted or
cancelled out from under itself. Clear it on failure as well as success,
or the field stays stuck.
Setting editing on its own, without can-edit, also stops the
component closing itself. The difference is that can-edit keeps the
field controlled while the editor is closed, which is what a pencil
click needs in order to reach you as an edit event.
To supply your own affordance, add no-trigger and set editing
yourself.
<editable-field
name="email"
value="hi@example.com"
can-edit
no-trigger
></editable-field>
<button type="button" id="edit-email">Edit email</button>document.getElementById('edit-email').addEventListener('click', () => {
field.setAttribute('editing', '')
})The package ships with default styles:
import '@substrate-system/editable-field/css'
// or for the minified output:
import '@substrate-system/editable-field/min/css'Include these imports once in your CSS/JS entry point before you render the elements so the buttons and outlines behave as shipped.
--editable-field-gap(0.5rem): Gap between the input and the button trio.--pencil-button-color(currentcolor): Stroke color for the pencil icon.--pencil-button-size(1.5rem): Width/height of the pencil icon.--save-button-color(currentcolor): Stroke color for the save checkmark.--save-button-size(1.5rem): Width/height of the save icon.--x-button-color(currentcolor): Stroke color for the cancel "x".--x-button-size(1.5rem): Width/height of the cancel icon.--save-button-pending-opacity(0.5): Opacity of the save button whilependingis set.--x-button-pending-opacity(0.5): Opacity of the cancel button whilependingis set.
Override any of these variables on the host to restyle the buttons or the spacing without touching the component internals.
editable-field {
--editable-field-gap: 1rem;
--save-button-color: #0b99ff;
--x-button-color: #e66;
}If you prefer a drop-in script, copy the bundled JS/CSS from dist/ into
your public folder.
cp ./node_modules/@substrate-system/editable-field/dist/index.min.js ./public/editable-field.min.js
cp ./node_modules/@substrate-system/editable-field/dist/style.min.css ./public/editable-field.css<head>
<link rel="stylesheet" href="./editable-field.css">
</head>
<body>
<script type="module" src="./editable-field.min.js"></script>
</body>- The component keeps
aria-disabled="true"on the host until the pencil button is clicked;input.focus()runs as soon as editing starts. - Save/cancel buttons are hidden by default and shown via the
.editingclass when editing is active. The pencil button is hidden while in edit mode. - The cancel flow restores the cached
_originalValuebefore it emitscancel. pendingships no spinner. It dims the disabled buttons and adds apendingclass to the host, so any busier treatment is yours to write.- To inspect live logging, set
localStorage.DEBUGtoeditable-fieldand runexample/index.tsduring development.