Inline click-to-edit text — a task title, a project name, a description. At rest it reads as the surrounding text: it inherits the context’s typography (an <h2>, a table cell, a list row) and only tints on hover. Click it and the text becomes editable in place — the caret lands where you clicked and nothing shifts, because the edit field is laid out by exactly the same rules as the text it replaces. Enter or blur saves, Escape cancels.
The headline case: an Editable inside a heading takes the heading’s size and weight. Long titles wrap across lines, and keep wrapping identically while you edit.
---
// GENERATED from packages/demos/editable/title.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import { css } from "@pindoba/styled-system/css";
---
<h2 class={css({ fontSize: "2xl", fontWeight: "semibold", margin: 0 })}>
<Editable
defaultValue="Migrate the billing service to the new queue"
label="Task title"
/>
</h2>multiline allows line breaks: Enter inserts a newline and ⌘/Ctrl + Enter saves. Single-line editables still wrap long text visually, but strip newlines (a pasted block of lines joins with spaces).
---
// GENERATED from packages/demos/editable/description.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
---
<Editable
multiline
label="Description"
placeholder="Add a description…"
defaultValue="Move the invoice workers off the legacy cron jobs.
Keep the retry policy and the dead-letter queue."
/>The hover tint and the edit border need a little room around the text. By default the editable takes that room outside its text (bleed): the box reaches past the text by its padding, so the text itself lines up with whatever sits above and below it — a title with its meta row, a value with its column. Leave a little space around it for the box, and turn bleed off where there is none (an edge-to-edge row, an overflow: hidden cell): the padding then sits inside and the text is inset by it.
Editing is seamless by default: no border or fill appears, the text just takes a caret, so a title stays a title while you type. In form-like rows — a settings grid, a comment composer — use editAppearance="field" to show an input box while editing. An invalid editable shows its red border either way.
An empty value shows the placeholder in the muted text color, both at rest and while editing. Saving an empty (or whitespace-only) draft reverts to the previous value — pass revertOnEmpty={false} to allow clearing it.
---
// GENERATED from packages/demos/editable/placeholder.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
---
<Editable label="Task title" placeholder="Untitled task" />controls adds a pencil button beside the text (shown on hover and focus) and save / cancel buttons while editing. Their accessible names are prefixed with label (“Edit Project name”); translate them with labels. A disabled or readOnly editable shows no controls — there is nothing to edit.
---
// GENERATED from packages/demos/editable/controls.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
---
<Editable controls label="Project name" defaultValue="Northwind portal" />activationMode sets what enters edit mode from the text: click (default), dblclick for dense UIs where a single click selects, focus to start editing as soon as the text is tabbed to, or none so only the edit button (or the API) does. With every mode but none, Enter or Space on the focused text starts editing too. Text the user selected first — the word a double-click picks, or a dragged range — carries into the field (with the default selectOnEdit="caret"; an explicit all or end wins). Outside inline mode the editable takes the whole line, a flex row included, so a click anywhere across a list row’s width activates it. submitMode sets what saves: both (default), enter, blur or none — when blur doesn’t save, it cancels.
---
// GENERATED from packages/demos/editable/activation.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const label = css({ color: "neutral.text.muted", fontSize: "sm" });
---
<div class={stack({ gap: "md" })}>
<div>
<span class={label}>Double click</span>
<Editable
activationMode="dblclick"
label="Double click"
defaultValue="Double-click to rename"
/>
</div>
<div>
<span class={label}>Focus</span>
<Editable
activationMode="focus"
label="Focus"
defaultValue="Tab here to start editing"
/>
</div>
<div>
<span class={label}>Edit button only</span>
<Editable
activationMode="none"
controls
label="Edit button only"
defaultValue="Use the pencil to edit"
/>
</div>
</div>Pass value and update it from onValueCommit, which fires once per save that changes the value. onValueChange reports every keystroke of the draft, onValueRevert a cancel, and onEditChange the edit mode (with why it ended: enter, blur, button, escape). In Astro the same moments are editable:change, editable:commit, editable:revert and editable:editchange events on the root.
Saved “Quarterly planning” · 0 saves
---
// GENERATED from packages/demos/editable/controlled.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const title = "Quarterly planning";
const saves = 0;
---
<div class={stack({ gap: "sm" })} data-ir-root="editable/controlled">
<Editable label="Board name" value={title} data-ir="1" />
<p class={css({ color: "neutral.text.muted", fontSize: "sm" })}>
Saved “<!--ir:2-->
{title}
<!--/ir-->” · <!--ir:3-->
{saves}
<!--/ir--> saves
</p>
</div>
<script>
import { hydrate } from "./_client/controlled.ts";
document
.querySelectorAll('[data-ir-root="editable/controlled"]')
.forEach((root) => hydrate([root]));
</script>disabled dims the text and takes it out of the tab order; readOnly shows the value without allowing edits; invalid turns the edit border red and sets aria-invalid.
---
// GENERATED from packages/demos/editable/states.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "sm" })}>
<Editable disabled label="Disabled" defaultValue="Disabled" />
<Editable readOnly label="Read-only" defaultValue="Read-only" />
<Editable invalid label="Invalid" defaultValue="Name already taken" />
</div>With name, a hidden input carries the committed value, so the editable submits with its form. Keep blur saving here (the default submitMode): with enter or none, clicking the form’s submit button blurs the field first, which cancels the edit just before the form reads it.
---
// GENERATED from packages/demos/editable/form.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const submitted = "";
---
<form
class={stack({ gap: "sm", align: "start" })}
data-ir-root="editable/form"
data-ir="1"
>
<Editable name="title" label="Title" defaultValue="Release notes" />
<Button type="submit">Submit</Button>
<!--ir:2-->
{submitted && (
<p class={css({ color: "neutral.text.muted", fontSize: "sm" })}>
Submitted title: <!--ir:3-->
{submitted}
<!--/ir-->
</p>
)}
<!--/ir-->
</form>
<script>
import { hydrate } from "./_client/form.ts";
document
.querySelectorAll('[data-ir-root="editable/form"]')
.forEach((root) => hydrate([root]));
</script>Inside a row that paints its own hover (a list, a tree, a table), drop the editable’s tint with background="none" and use activationMode="dblclick" so a single click stays a selection.
---
// GENERATED from packages/demos/editable/list.demo.tsx — edit the source, not this file.
import Editable from "@pindoba/astro-editable";
import { css } from "@pindoba/styled-system/css";
const rows = [
{ id: "design", name: "Design review" },
{ id: "api", name: "API contract" },
{ id: "launch", name: "Launch checklist" },
];
const list = css({
display: "flex",
flexDirection: "column",
gap: "3xs",
listStyle: "none",
margin: 0,
padding: 0,
maxWidth: "320px",
});
const row = css({
borderRadius: "sm",
_hover: { background: "neutral.surface.base" },
});
---
<ul class={list}>
{rows.map((item) => (
<li class={row}>
<Editable
background="none"
activationMode="dblclick"
label="Section name"
defaultValue={item.name}
/>
</li>
))}
</ul>| Key | Where | Action |
|---|---|---|
| Enter / Space | text | Start editing (caret at the end) |
| Enter | field | Save (single-line); newline (multiline) |
| ⌘/Ctrl + Enter | field | Save (multiline) |
| Escape | field | Cancel and restore the value |
| Tab | field | Leave the field — saves (submitMode permitting) |
role="button" in the tab order, named "<label>: <value>". While editing it stays in place as an aria-hidden sizer and the <textarea> takes focus, named by label (aria-multiline when multiline).