Editable
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/editablePlain-CSS theme — no build step
npx astrocraft-ui add forms/editable --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/editable --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/editable --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/editable --theme tailwind --bridge lumosLive demo
Editable
Click (or tab to and press Enter on) the title. Enter commits, Escape puts back what was there when the edit started, clicking away commits.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Editable.astro` | `editable` | — | `data-editing` `data-empty` | — |
| `EditableInput.astro` | `editable-input` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
| `EditablePreview.astro` | `editable-preview` | — | — | — |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/editable/Editable.astro — headless primitive (see ../../README.md).
// Click-to-edit: a preview that swaps for a real input, with the keyboard contract people expect —
// Enter commits, Escape reverts to the value the edit started from, blur commits.
//
// The swap is the `hidden` ATTRIBUTE on each half, never a class (contract rule 4), which is why
// this file imports structure.css: without `[hidden] { display: none !important }` a theme's own
// `display` on either half wins and BOTH render at once.
//
// Both halves render visible, and the script hides the input on init — the same progressive
// enhancement Tabs uses. Get it backwards and a reader without JavaScript sees only a preview with
// no way to edit; this way they get a plain, working input.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div">;
const { class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="editable" data-editing="false" {...rest}>
<slot />
</div>
<script>
import { onReadyOnce } from "../../_once";
function wire(root: HTMLElement) {
const preview = root.querySelector<HTMLElement>('[data-slot="editable-preview"]');
const input = root.querySelector<HTMLInputElement>('[data-slot="editable-input"]');
if (!preview || !input) return;
let started = input.value;
const show = (editing: boolean) => {
input.hidden = !editing;
preview.hidden = editing;
root.dataset.editing = String(editing);
};
const paint = () => {
const value = input.value.trim();
preview.textContent = value || (preview.dataset.placeholder ?? "");
// An empty value renders as the placeholder, which reads the same as a real one — the
// attribute is what tells a theme (and a reader of the DOM) the difference.
preview.dataset.empty = String(value === "");
};
const start = () => {
started = input.value;
show(true);
input.focus();
input.select();
};
const stop = (commit: boolean) => {
if (root.dataset.editing !== "true") return;
if (!commit) input.value = started;
show(false);
paint();
preview.focus();
if (commit && input.value !== started) {
input.dispatchEvent(new Event("change", { bubbles: true }));
}
};
preview.addEventListener("click", start);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault(); // an Enter in a form would submit it instead of committing the edit
stop(true);
} else if (event.key === "Escape") {
event.stopPropagation(); // don't close the dialog or popover this edit may live in
stop(false);
}
});
input.addEventListener("blur", () => stop(true));
paint();
show(false);
}
onReadyOnce('[data-slot="editable"]', wire);
</script>
---
// src/components/ui/forms/editable/EditableInput.astro — Editable compound part (see ../../README.md).
// The editing state: a real <input>, so it submits with a form and gets the platform's own text
// editing, selection and IME behavior for free. Give it an accessible name with `aria-label` or an
// associated <Label> — the preview's text is not one.
// Carries the shared `data-size` / `data-state` field surface.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const { size = "md", state = "default", class: className, ...rest } = Astro.props;
---
<input class={className} data-slot="editable-input" data-size={size} data-state={state} {...rest} />
---
// src/components/ui/forms/editable/EditablePreview.astro — Editable compound part (see ../../README.md).
// The resting state. A real <button>, so it is reachable by keyboard and announced as activatable —
// a <div onclick> here would be invisible to anyone not using a mouse, which is the usual way this
// pattern gets built wrong.
//
// `placeholder` is what shows when the value is empty; the script also marks that case with
// `data-empty="true"` so a theme can dim it without having to guess from the text.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { placeholder?: string };
const { placeholder, class: className, ...rest } = Astro.props;
---
<button
type="button"
class={className}
data-slot="editable-preview"
data-placeholder={placeholder}
{...rest}
>
<slot />
</button>
import Editable from "./Editable.astro";
import EditableInput from "./EditableInput.astro";
import EditablePreview from "./EditablePreview.astro";
export { Editable, EditableInput, EditablePreview };
export default Editable;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/editable — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.