Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Editable

A text shown in place that turns into a field when pressed.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { Editable } from '@hamur/react/editable'
import { Field } from '@hamur/react/field'

export default function EditableExample() {
  const [name, setName] = React.useState('Ada Lovelace')
  return (
    <Field.Root className="field">
      <Field.Label>Display name</Field.Label>
      <Editable.Root
        className="editable"
        value={name}
        placeholder="Add a display name"
        onValueChange={(next: string) => setName(next)}
      >
        <Editable.Preview className="editable-preview" />
        <Editable.Input className="editable-input" />
      </Editable.Root>
      <Field.Description className="muted">
        Press the name to edit it; Enter saves and Escape cancels.
      </Field.Description>
    </Field.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Editable.Root>
  <Editable.Preview />
  <Editable.Input />
</Editable.Root>

// Not in this example: Editable.CancelTrigger, Editable.EditTrigger, Editable.SubmitTrigger

Examples

Multiline

A bio in a textarea with autoResize: Enter types a line break, Control+Enter or Meta+Enter saves, and the Preview keeps the line breaks.

EditableMultiline.tsx
import * as React from 'react'
import { Editable } from '@hamur/react/editable'
import { Field } from '@hamur/react/field'

export default function EditableMultilineExample() {
  const [bio, setBio] = React.useState(
    'Mathematician and writer.\nWrote the first published program.',
  )
  return (
    <Field.Root className="field">
      <Field.Label>Bio</Field.Label>
      <Editable.Root
        className="editable"
        value={bio}
        autoResize
        placeholder="Add a bio"
        onValueChange={(next: string) => setBio(next)}
      >
        <Editable.Preview className="editable-preview" />
        <Editable.Input className="editable-input" render={<textarea />} />
      </Editable.Root>
      <Field.Description className="muted">
        Enter adds a line; Ctrl+Enter or Cmd+Enter saves and Escape cancels.
      </Field.Description>
    </Field.Root>
  )
}

Triggers

A project title with activationMode="dblclick": a double-click or the EditTrigger starts an edit, and the SubmitTrigger and CancelTrigger end it.

EditableTriggers.tsx
import * as React from 'react'
import { Editable } from '@hamur/react/editable'
import { Field } from '@hamur/react/field'

export default function EditableTriggersExample() {
  const [title, setTitle] = React.useState('Quarterly report')
  return (
    <Field.Root className="field">
      <Field.Label>Project</Field.Label>
      <Editable.Root
        className="editable"
        value={title}
        activationMode="dblclick"
        onValueChange={(next: string) => setTitle(next)}
      >
        <Editable.Preview className="editable-preview" />
        <Editable.Input className="editable-input" />
        <Editable.EditTrigger className="editable-trigger">
          <svg className="editable-icon" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M11 2.5l2.5 2.5L6 12.5H3.5V10z" />
          </svg>
        </Editable.EditTrigger>
        <Editable.SubmitTrigger className="editable-trigger">
          <svg className="editable-icon" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M3 8.5l3 3 7-7" />
          </svg>
        </Editable.SubmitTrigger>
        <Editable.CancelTrigger className="editable-trigger">
          <svg className="editable-icon" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M4 4l8 8M12 4l-8 8" />
          </svg>
        </Editable.CancelTrigger>
      </Editable.Root>
      <Field.Description className="muted">
        Double-click the title, or press Edit, to rename it.
      </Field.Description>
    </Field.Root>
  )
}

Form

A username in a Form: required refuses an empty save, cancel() in onValueChange refuses other characters, and the hidden input submits the text.

EditableForm.tsx
import * as React from 'react'
import { Editable } from '@hamur/react/editable'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'

const USERNAME = /^[a-z0-9-]+$/

export default function EditableFormExample() {
  const [username, setUsername] = React.useState('')
  const [refused, setRefused] = React.useState('')
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="username" className="field">
        <Field.Label>Username</Field.Label>
        <Editable.Root
          className="editable"
          value={username}
          required
          placeholder="Choose a username"
          onValueChange={(next: string, details: { cancel: () => void }) => { if (!USERNAME.test(next)) { details.cancel() setRefused('Use lowercase letters, digits and hyphens.') return } setUsername(next) setRefused('') }}
        >
          <Editable.Preview className="editable-preview" />
          <Editable.Input className="editable-input" />
        </Editable.Root>
        <Field.Description className="muted">
          Lowercase letters, digits and hyphens.
        </Field.Description>
        {refused ? <p className="error">{refused}</p> : null}
        <Field.Error className="error" match="valueMissing">
          Choose a username.
        </Field.Error>
      </Field.Root>
      <button className="button" type="submit">
        Save
      </button>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

25 props across 6 parts, in React syntax. className, style, render are listed once, under Shared props.

CancelTriggerEditable.CancelTrigger

A button that ends the edit without a commit, rendered while editing. Pressing it keeps focus in the Input until it acts. Named "Cancel" and the Field label. Renders a <button>.

CancelTrigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
CancelTrigger data attributes
Data attributeDescription
data-disabledPresent when the trigger is disabled.

EditTriggerEditable.EditTrigger

A button that starts an edit, rendered while not editing. While it is mounted it is the component's tab stop, and the Preview leaves the tab order. Named "Edit" and the Field label. Renders a <button>.

EditTrigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
EditTrigger data attributes
Data attributeDescription
data-disabledPresent when the trigger is disabled.

InputEditable.Input

The text field that edits the value, rendered while editing in place of the Preview. Enter commits and Escape cancels; render={<textarea />} makes it multiline, where Ctrl+Enter or Cmd+Enter commits. Renders an <input>.

Input data attributes
Data attributeDescription
data-editingPresent while the Input is shown.
data-emptyPresent when the value is empty.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is read-only.
data-requiredPresent when the value may not be empty.
data-autoresizePresent when the Input is sized to its content.
data-validPresent when the field is valid.
data-invalidPresent when the field is invalid.
data-dirtyPresent when the value has changed.
data-touchedPresent when focus has left the component.
data-filledPresent when the value is not empty.
data-focusedPresent while focus is inside the component.
data-rejectedPresent from a refused commit until the text changes.

PreviewEditable.Preview

The value shown as a button that starts an edit, or the placeholder while the value is empty. Rendered while not editing. With activationMode="none" it is plain text. Renders a <span>.

Preview props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleanfalse
Preview data attributes
Data attributeDescription
data-editingPresent while the Input is shown.
data-emptyPresent when the value is empty.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is read-only.
data-requiredPresent when the value may not be empty.
data-autoresizePresent when the Input is sized to its content.
data-validPresent when the field is valid.
data-invalidPresent when the field is invalid.
data-dirtyPresent when the value has changed.
data-touchedPresent when focus has left the component.
data-filledPresent when the value is not empty.
data-focusedPresent while focus is inside the component.

RootEditable.Root

Groups all parts of the editable: a text shown as a button that turns into a text field to edit it in place. with the hidden inputs, followed by the status region. Renders a <div>.

Root props
PropTypeDefault
valueThe committed text.string—
defaultValueThe committed text when the component is initially rendered.string—
onValueChangeEvent handler called once per commit that changes the text.((value: string, eventDetails: EditableRootChangeEventDetails) => void)—
inputValueThe text in the Input while editing.string—
onInputValueChangeEvent handler called when the text in the Input changes, and with the value when an edit((inputValue: string, eventDetails: EditableRootInputValueChangeEventDetails) => void)—
editingWhether the Input is shown.boolean—
defaultEditingWhether the Input is shown when the component is initially rendered.booleanfalse
onEditingChangeEvent handler called when an edit starts or ends.((editing: boolean, eventDetails: EditableRootEditingChangeEventDetails) => void)—
activationModeWhat starts an edit from the Preview: a click, a double-click, focus, or nothing (only theEditableActivationMode'click'
submitModeWhat commits the draft: Enter, focus leaving the component, both, or neither (only theEditableSubmitMode'both'
selectOnFocusWhether the text is selected when an edit starts; otherwise the caret goes to the end.booleantrue
placeholderThe Preview's text while the value is empty, and the Input's placeholder unless it setsstring—
autoResizeWhether the Input is sized to its content.booleanfalse
finalFocusWhere focus goes after an edit ends by a key, a trigger or actionsRef: true the Previewboolean | React.RefObject<HTMLElement | null> | (() => HTMLElement | null | undefined)—
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to start an edit.booleanfalse
requiredWhether the value may not be empty.booleanfalse
nameIdentifies the field when a form is submitted.string—
formIdentifies the form that owns the hidden inputs.string—
actionsRefA ref to start, commit or cancel an edit from the app.React.RefObject<EditableRootActions | null>—
labelsThe texts, in English by default: the triggers' names and the refusal announcement.Partial<EditableLabels>—
Root data attributes
Data attributeDescription
data-editingPresent while the Input is shown.
data-emptyPresent when the value is empty.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is read-only.
data-requiredPresent when the value may not be empty.
data-autoresizePresent when the Input is sized to its content.
data-validPresent when the field is valid.
data-invalidPresent when the field is invalid.
data-dirtyPresent when the value has changed.
data-touchedPresent when focus has left the component.
data-filledPresent when the value is not empty.
data-focusedPresent while focus is inside the component.

SubmitTriggerEditable.SubmitTrigger

A button that commits the draft, rendered while editing. Pressing it keeps focus in the Input until it acts. Named "Save" and the Field label. Renders a <button>.

SubmitTrigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
SubmitTrigger data attributes
Data attributeDescription
data-disabledPresent when the trigger is disabled.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
EnterSpaceStarts an edit, with the text selectedPreview
EnterSaves the text once, and does not submit the formInput
ControlEnterIn a textarea, saves the text; Enter alone types a line breakInput
EscapeCancels the edit and restores the textInput
TabLeaving the Root saves the text; moving to Save or Cancel does notInput
EnterSpaceStarts an editEditTrigger
EnterSpaceSaves the textSubmitTrigger
EnterSpaceCancels the edit and restores the textCancelTrigger

Accessibility

What the parts set
PartSetsSo that
Previewa span with role="button", named by the Field label and its own text, and described by the Field's description and errora reader hears "Display name Ada Lovelace, button" and knows a press edits it
Previewwith activationMode="none", no role and no tabindextext that only the EditTrigger edits is read as text, not as a button that does nothing
Inputan input of type text, or a textarea, named by the Field label; aria-invalid while a save is refusedthe field is read with its label, and a refused save is heard on the field
EditTriggera button named "Edit" and the Field label; while it is shown it is the Tab stop and the Preview is at tabindex="-1"the component is one Tab stop, and the button says what it edits
SubmitTriggera button named "Save" and the Field label; a mousedown on it is preventeda press saves without first moving focus out of the Input
CancelTriggera button named "Cancel" and the Field label; a mousedown on it is preventeda press cancels without first saving on blur
Rootone polite role="status" region after the Root, and with a name a hidden input with the committed texta refused save is announced ("Not saved", or the Input's own message), and the form gets it

Focus order

  1. The Preview is the Tab stop, or the EditTrigger when there is one; Tab onto the Preview starts nothing unless activationMode is focus.
  2. While editing, the Input takes focus with its text selected, then Save and Cancel follow it in the Tab order.
  3. Focus leaving the Root saves the text, and Enter, Escape and the triggers return focus to the Tab stop.