Inputs

Inline edit

Text that becomes an input when clicked, for renaming things in place, with pending and error states.

Built on
<button><input>aria-livearia-describedby
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.7 kB
Dependencies
React
Project

Try its options

Loading its options.

Code
import { InlineEdit } from "@/components/equip/inline-edit";

export default function Demo() {
  return (
    <div style={{ display: "grid", gap: 4, width: 216 }}>
      <span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>Project</span>
      <InlineEdit
        label="Project name"
        defaultValue="Larder billing"
        style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}
      />
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Pending and refused

onSave returns a promise: the input goes read-only with a spinner until it settles. Try a handle that is already taken, such as ana.

Handle

Bio

Code
"use client";

import { InlineEdit } from "@/components/equip/inline-edit";

const taken = ["ana", "admin", "support"];

function check(handle: string): Promise<string | void> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const clean = handle.trim().toLowerCase();
      if (!/^[a-z0-9-]{3,20}$/.test(clean)) resolve("Use 3 to 20 letters, numbers or dashes.");
      else if (taken.includes(clean)) resolve(`${clean} is taken.`);
      else resolve();
    }, 900);
  });
}

export default function Example() {
  return (
    <dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", alignItems: "start", margin: 0, fontSize: "var(--eq-text-m)" }}>
      <dt style={{ color: "var(--eq-ink-2)", paddingTop: 3 }}>Handle</dt>
      <dd style={{ margin: 0 }}>
        <InlineEdit label="Handle" defaultValue="ana-okafor" maxLength={20} onSave={check} />
      </dd>
      <dt style={{ color: "var(--eq-ink-2)", paddingTop: 3 }}>Bio</dt>
      <dd style={{ margin: 0 }}>
        <InlineEdit label="Bio" placeholder="Add a short bio" maxLength={80} />
      </dd>
    </dl>
  );
}

Renaming rows

Controlled values with blurAction="cancel", so clicking away in a dense list never saves by accident; only Enter does.

  • sk_…k1f9
  • sk_…k2f9
  • sk_…k3f9
Code
"use client";

import { useState } from "react";
import { InlineEdit } from "@/components/equip/inline-edit";

export default function Example() {
  const [keys, setKeys] = useState([
    { id: "k1", name: "Production deploys" },
    { id: "k2", name: "Staging" },
    { id: "k3", name: "Local testing" },
  ]);

  return (
    <ul style={{ display: "grid", gap: 4, width: "100%", maxWidth: 300, margin: 0, padding: 0, listStyle: "none", fontSize: "var(--eq-text-m)" }}>
      {keys.map((key) => (
        <li
          key={key.id}
          style={{ display: "flex", justifyContent: "space-between", gap: 12, padding: "6px 10px", border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}
        >
          <InlineEdit
            label="Key name"
            value={key.name}
            blurAction="cancel"
            onSave={(next) => {
              if (!next.trim()) return "A key needs a name.";
              setKeys((all) => all.map((k) => (k.id === key.id ? { ...k, name: next.trim() } : k)));
            }}
          />
          <span style={{ color: "var(--eq-ink-3)", fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)", fontSize: "var(--eq-text-xs)", paddingTop: 4 }}>
            sk_…{key.id}f9
          </span>
        </li>
      ))}
    </ul>
  );
}

In a form

With name set, a hidden input carries the saved value. Enter inside the editor saves the text and never submits the form.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { InlineEdit } from "@/components/equip/inline-edit";

export default function Example() {
  const [sent, setSent] = useState("");
  return (
    <form
      style={{ display: "grid", gap: 12, justifyItems: "start", fontSize: "var(--eq-text-m)" }}
      onSubmit={(event) => {
        event.preventDefault();
        setSent(String(new FormData(event.currentTarget).get("invoice_title")));
      }}
    >
      <InlineEdit label="Invoice title" name="invoice_title" defaultValue="November retainer" style={{ fontWeight: "var(--eq-weight-strong)" }} />
      <Button type="submit" variant="outline" size="s">
        Send invoice
      </Button>
      {sent && <p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>invoice_title = {sent}</p>}
    </form>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneWhat the value is, such as Project name. Names the button and the input; not shown.
valuestringnoneControlled value.
defaultValuestring""Uncontrolled starting value.
onSave(value: string) => void | string | Promise<void | string>noneCalled with the new text when it changed. Return or resolve a string to refuse the value and show it as the error; a rejected promise shows its message. While a promise is pending the input is read-only.
onCancel() => voidnoneCalled when editing ends without saving.
placeholderstring"Empty"Shown in place of an empty value and as the input's placeholder.
blurAction"save" | "cancel""save"What leaving the input does. Use cancel in dense lists where a stray click should not save.
namestringnoneAdds a hidden input carrying the saved value, so it submits with a form.
maxLengthnumbernonePassed to the input.
disabledbooleannoneShows the value without letting it be edited.
...restComponentProps<"div">noneEvery native div attribute on the wrapper, including style, which sets the font the text and input inherit.

Anatomy

root.eq-inline-editdata-editing while the input shows, data-pending while a save is in flight.
display.eq-inline-edit-displayThe button that shows the value and opens the editor.
text.eq-inline-edit-textdata-empty when the placeholder is shown instead.
icon.eq-inline-edit-iconPencil shown on hover and focus, always on touch screens.
field.eq-inline-edit-fieldHolds the input and the spinner.
input.eq-inline-edit-input
spinner.eq-inline-edit-spinnerShown while onSave's promise is pending.
error.eq-inline-edit-errorPolite live region, linked to the input while it has a message.
status.eq-inline-edit-statusVisually hidden status that announces a save.

Keyboard

EnterSpaceOn the text, opens the editor with the text selected.
EnterIn the editor, saves. It never submits a surrounding form, and a held key does not save straight away.
EscapeCancels and puts the old text back, without closing a surrounding dialog.
TabLeaves the editor, saving or cancelling as blurAction says.

Notes

  • The resting text is a real button named Edit, label, value, so it is reachable by Tab and says what it does.
  • After a save or a cancel, focus returns to the text, unless the person has already moved focus somewhere else.
  • A refused value keeps the editor open with aria-invalid and the message as its description; the message is also announced.
  • Saving text that did not change closes the editor without calling onSave.
  • Font, size and weight come from where it is placed, so the same part works in a heading, a table cell or a list row.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/inline-edit.tsx219 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, FocusEvent, KeyboardEvent } from "react";
import "./inline-edit.css";

/** What onSave may hand back: nothing to accept the value, or a message to keep editing and show it. */
type SaveResult = void | string | null | undefined;

export type InlineEditProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** What the value is, such as "Project name". Names the button and the input; not shown. */
  label: string;
  /** Controlled value. */
  value?: string;
  /** Uncontrolled starting value. */
  defaultValue?: string;
  /**
   * Called with the new text on Enter. Return or resolve a string to refuse it with that message;
   * a rejected promise shows its message. While a promise is pending the input is read-only.
   */
  onSave?: (value: string) => SaveResult | Promise<SaveResult>;
  /** Called when editing ends without saving. */
  onCancel?: () => void;
  /** Shown in place of an empty value, and as the input's placeholder. */
  placeholder?: string;
  /** What leaving the input does. */
  blurAction?: "save" | "cancel";
  /** Adds a hidden input with the saved value, so it submits with a form. */
  name?: string;
  maxLength?: number;
  disabled?: boolean;
};

function messageOf(reason: unknown) {
  return reason instanceof Error && reason.message ? reason.message : "Could not save. Try again.";
}

export function InlineEdit({
  label,
  value,
  defaultValue = "",
  onSave,
  onCancel,
  placeholder = "Empty",
  blurAction = "save",
  name,
  maxLength,
  disabled,
  className,
  ...rest
}: InlineEditProps) {
  const id = useId();
  const errorId = `${id}-error`;
  const [inner, setInner] = useState(defaultValue);
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const shown = value ?? inner;
  const display = useRef<HTMLButtonElement>(null);
  const input = useRef<HTMLInputElement>(null);
  /** Where focus goes after the next render: into the input, back to the button, or nowhere. */
  const focusNext = useRef<"input" | "display" | null>(null);
  const mounted = useRef(true);

  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);

  useEffect(() => {
    const target = focusNext.current;
    if (!target) return;
    focusNext.current = null;
    if (target === "input") {
      input.current?.focus();
      input.current?.select();
    } else if (!document.activeElement || document.activeElement === document.body) {
      // Only when focus was lost with the input; if the person has moved on, leave it there.
      display.current?.focus();
    }
  });

  function start() {
    if (disabled) return;
    setDraft(shown);
    setError("");
    setStatus("");
    setEditing(true);
    focusNext.current = "input";
  }

  function finish(next: string | null, refocus: boolean) {
    if (next !== null) {
      if (value === undefined) setInner(next);
      setStatus(`${label} saved`);
    }
    setEditing(false);
    setError("");
    setPending(false);
    if (refocus) focusNext.current = "display";
  }

  function cancel(refocus: boolean) {
    finish(null, refocus);
    onCancel?.();
  }

  async function save(refocus: boolean) {
    if (pending) return;
    const next = draft;
    if (next === shown) {
      finish(null, refocus);
      return;
    }
    let result: SaveResult;
    try {
      const returned = onSave?.(next);
      if (returned instanceof Promise) {
        setPending(true);
        setError("");
        result = await returned;
      } else {
        result = returned;
      }
    } catch (reason) {
      result = messageOf(reason);
    }
    if (!mounted.current) return;
    if (typeof result === "string" && result) {
      setPending(false);
      setError(result);
      // Back in the input so the message and the text are both in front of the person.
      if (refocus) focusNext.current = "input";
      return;
    }
    finish(next, refocus);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter") {
      // Never submits a surrounding form, and a held Enter from opening the editor does not save at once.
      event.preventDefault();
      if (event.repeat || event.nativeEvent.isComposing) return;
      void save(true);
    } else if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      if (!pending) cancel(true);
    }
  }

  function handleBlur(event: FocusEvent<HTMLInputElement>) {
    if (pending || event.currentTarget.contains(event.relatedTarget)) return;
    if (blurAction === "save") void save(false);
    else cancel(false);
  }

  return (
    <div
      {...rest}
      className={["eq-inline-edit", className].filter(Boolean).join(" ")}
      data-editing={editing || undefined}
      data-pending={pending || undefined}
    >
      {editing ? (
        <span className="eq-inline-edit-field">
          <input
            ref={input}
            className="eq-inline-edit-input"
            type="text"
            aria-label={label}
            aria-invalid={error ? true : undefined}
            aria-describedby={error ? errorId : undefined}
            aria-busy={pending || undefined}
            readOnly={pending}
            placeholder={placeholder}
            maxLength={maxLength}
            value={draft}
            size={Math.max(4, draft.length + 1)}
            onChange={(event) => {
              setDraft(event.target.value);
              if (error) setError("");
            }}
            onKeyDown={handleKeyDown}
            onBlur={handleBlur}
          />
          {pending && <span className="eq-inline-edit-spinner" aria-hidden="true" />}
        </span>
      ) : (
        <button
          ref={display}
          type="button"
          className="eq-inline-edit-display"
          aria-label={`Edit ${label}, ${shown || "empty"}`}
          disabled={disabled}
          onClick={start}
        >
          <span className="eq-inline-edit-text" data-empty={shown ? undefined : true}>
            {shown || placeholder}
          </span>
          <svg className="eq-inline-edit-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M10.5 3 13 5.5 6 12.5l-3 .5.5-3 7-7Z" />
          </svg>
        </button>
      )}
      <p className="eq-inline-edit-error" id={errorId} aria-live="polite">
        {editing ? error : ""}
      </p>
      <span className="eq-inline-edit-status" role="status">
        {status}
      </span>
      {name && <input type="hidden" name={name} value={shown} />}
    </div>
  );
}
components/equip/inline-edit.css176 lines
@layer equip {
  /* Reads as the text it edits: font and size come from wherever it is placed. */
  .eq-inline-edit {
    --_pad-x: var(--eq-space-1);
    --_pad-y: 2px;

    display: inline-grid;
    gap: var(--eq-space-1);
    min-width: 0;
    max-width: calc(100% + 2 * var(--_pad-x) + 2px);
    /* Pulled out by the box's padding, so the text lines up with the text around it. */
    margin-inline: calc(-1 * var(--_pad-x) - 1px);
    vertical-align: top;
    color: var(--eq-ink);
  }

  .eq-inline-edit-display,
  .eq-inline-edit-input {
    box-sizing: border-box;
    min-height: 24px;
    margin: 0;
    padding: var(--_pad-y) var(--_pad-x);
    border: 1px solid transparent;
    border-radius: var(--eq-radius-s);
    color: inherit;
    font: inherit;
    line-height: 1.35;
  }

  .eq-inline-edit-display {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
    max-width: 100%;
    background: transparent;
    text-align: start;
    cursor: text;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-inline-edit-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-inline-edit-text[data-empty] {
    color: var(--eq-ink-3);
  }

  /* The pencil says it can be edited; it shows on hover and focus so resting text stays quiet. */
  .eq-inline-edit-icon {
    flex: none;
    width: 14px;
    height: 14px;
    color: var(--eq-ink-3);
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-inline-edit-display:focus-visible .eq-inline-edit-icon {
    opacity: 1;
  }

  @media (hover: hover) {
    .eq-inline-edit-display:hover:not(:disabled) {
      background: var(--eq-tint);
    }

    .eq-inline-edit-display:hover:not(:disabled) .eq-inline-edit-icon {
      opacity: 1;
    }
  }

  /* Touch has no hover, so the pencil stays visible there. */
  @media (hover: none) {
    .eq-inline-edit-icon {
      opacity: 1;
    }
  }

  .eq-inline-edit-display:active:not(:disabled) {
    scale: var(--eq-press);
  }

  .eq-inline-edit-display:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-inline-edit-display:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-inline-edit-field {
    position: relative;
    display: inline-flex;
    align-items: center;
    min-width: 0;
  }

  .eq-inline-edit-input {
    min-width: 6ch;
    max-width: 100%;
    border-color: var(--eq-line-strong);
    background: var(--eq-raised);
    appearance: none;
  }

  .eq-inline-edit-input::placeholder {
    color: var(--eq-ink-3);
    opacity: 1;
  }

  .eq-inline-edit-input:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-inline-edit-input[aria-invalid="true"] {
    border-color: var(--eq-danger);
  }

  .eq-inline-edit[data-pending] .eq-inline-edit-input {
    padding-inline-end: calc(var(--_pad-x) + 18px);
    color: var(--eq-ink-2);
    cursor: progress;
  }

  .eq-inline-edit-spinner {
    position: absolute;
    inset-inline-end: var(--eq-space-1);
    width: 12px;
    height: 12px;
    border: 1.5px solid var(--eq-tint-strong);
    border-top-color: var(--eq-ink-2);
    border-radius: var(--eq-radius-full);
    animation: eq-inline-edit-spin 0.8s linear infinite;
  }

  @keyframes eq-inline-edit-spin {
    to {
      rotate: 1turn;
    }
  }

  .eq-inline-edit-error {
    margin: 0;
    padding-inline: calc(var(--_pad-x) + 1px);
    color: var(--eq-danger);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  /* Empty, the live region stays in the page but takes no room and no gap. */
  .eq-inline-edit-error:empty,
  .eq-inline-edit-status {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* A spinner still reports that something is happening, so it slows down rather than stops. */
  @media (prefers-reduced-motion: reduce) {
    .eq-inline-edit-spinner {
      animation-duration: 2.4s;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index