Inline edit
Text that turns into a field in place - rename a project, a column, a file.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-inline-edit.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-inline-edit.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-inline-edit.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-inline-edit.jsonRename-in-place has more states than it looks: showing, editing, saving, rejected. The usual version swaps a span for an input on click and handles two of them: leaving the keyboard with no way in, the Escape key closing the dialog it sits in, and a failed rename throwing away what was typed.
Click, or focus and press F2.
<script lang="ts">
import InlineEdit from '#lib/components/coral/kit/inline-edit/inline-edit.svelte';
let name = $state('api-gateway');
</script>
<div class="flex w-72 flex-col gap-2">
<!-- the same heading type in both states, reading and editing. -->
<InlineEdit
bind:value={name}
class="w-full text-lg font-medium"
placeholder="No name"
editLabel={(value) => `Rename ${value}`}
required
/>
<p class="text-sm text-muted-foreground">Click, or focus and press F2.</p>
</div>What Coral adds
- Reachable. The value is a real button: Tab reaches it, Enter and Space open it, and F2 does too, as in every file manager.
- Enter saves, Escape cancels, blur saves. Escape stops there instead of also closing a dialog.
saveOnBlur={false}makes leaving the field cancel instead. - Focus comes back. After Enter or Escape, focus returns to the value. After a click elsewhere it stays where the reader clicked.
- Waits, and survives rejection.
onsavecan be async. Returnfalseor throw and the field stays open with the text as typed, to be corrected rather than retyped. - Nothing to save, nothing saved. Unchanged text closes the field without calling
onsave.
Saving to a server
Try «api-gateway».
<script lang="ts">
import InlineEdit from '#lib/components/coral/kit/inline-edit/inline-edit.svelte';
const taken = ['api-gateway', 'design-tokens'];
let name = $state('web-app');
let error = $state('');
async function rename(next: string) {
error = '';
await new Promise((resolve) => setTimeout(resolve, 700));
if (taken.includes(next)) {
error = `A project named ${next} already exists.`;
return false;
}
}
</script>
<div class="flex w-72 flex-col gap-2">
<InlineEdit bind:value={name} class="w-full" onsave={rename} oncancel={() => (error = '')} />
<p class="text-sm text-muted-foreground" role="status">
{error || 'Try «api-gateway».'}
</p>
</div>While saving, the field is readonly rather than disabled: disabling a focused field blurs it, and
that blur would read as the reader leaving: saving a second time, or cancelling a save already on
its way.
Validation
required refuses empty text, validate refuses anything else, and sanitize (trimming, by
default) runs before both. A refused edit marks the field aria-invalid and stays open. What to say
about it is copy, and copy is the project's. Render the message next to the component.
Import
<script lang="ts">
import InlineEdit from '#lib/components/coral/kit/inline-edit/inline-edit.svelte';
</script>Props
Everything the shadcn input accepts goes to the field while editing: maxlength, aria-*, autocomplete. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | '' | Bindable. Only changes once a save goes through. |
editing | boolean | false | Bindable. Set true to start an edit from a menu. |
onsave | (value: string) => unknown | - | Async-aware. false or throw keeps the field open. |
onerror | (error: unknown) => void | - | What onsave threw. The field stays open, text as typed. |
oncancel | () => void | - | Escape, or blur with saveOnBlur off. |
validate | (value: string) => boolean | - | false refuses the text. |
sanitize | (raw: string) => string | trim | Cleans text before validating and saving. |
required | boolean | false | Refuses empty text. |
disabled | boolean | false | The value still shows; the button no longer starts an edit. |
saveOnBlur | boolean | true | Leaving the field saves. Off, it cancels. |
selectOnEdit | boolean | true | Selects the text when editing starts. |
placeholder | string | '' | Shown while empty, in both modes. |
editLabel | (value: string) => string | Edit {value} | Accessible name of the value button. |
class | string | - | Merged onto the value button and, while editing, the group. |
inputClass | string | - | Merged onto the field. |
display | Snippet<[{ value, placeholder }]> | - | Replaces what the value button shows. |
Accessibility
The default editLabel includes the visible value, so the accessible name contains the text on
screen. That's what WCAG 2.5.3 asks of a control a voice-control user will address by what it says. aria-busy is set while saving and aria-invalid after a refused edit.