# Example: Custom cell editor

A renderEditor select bridges a numeric priority column to and from the string a native control hands back, via formatEditValue and parseEditValue.

Source: https://pretable.ai/examples/custom-cell-editor.md

```tsx CustomEditorGrid.tsx
"use client";

import { useState } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { tasks, type Task } from "./data";

const VIEWPORT_HEIGHT = 200;

export function CustomEditorGrid() {
  const [rows, setRows] = useState<Task[]>(tasks);

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Edit Priority for Draft proposal, choose High, and press Enter to see a
        rejected save. Choose Medium or Low to retry. Enter/Shift+Enter save
        down/up; Tab/Shift+Tab save right/left. Escape cancels before saving.
        Leaving the field saves in place.
      </p>
      <PretableSurface<Task>
        ariaLabel="Tasks"
        columns={columns}
        getRowId={(row) => row.id}
        rows={rows}
        viewportHeight={VIEWPORT_HEIGHT}
        onRowChange={async ({ rowId, columnId, row }) => {
          await new Promise((resolve) => setTimeout(resolve, 600));
          if (columnId === "priority" && rowId === "t1" && row.priority === 3) {
            throw new Error("Choose Medium or Low for the proposal.");
          }
          setRows((previous) =>
            previous.map((candidate) =>
              candidate.id === rowId ? row : candidate,
            ),
          );
        }}
      />
    </div>
  );
}
```

```tsx PriorityEditor.tsx
import { useId, useRef } from "react";
import type { PretableEditorInput } from "@pretable/react";

import type { Task } from "./data";

// Keep the component at module scope so changing the draft preserves focus.
export function PriorityEditor({
  draft,
  setDraft,
  status,
  error,
  commit,
  cancel,
}: PretableEditorInput<Task>) {
  const errorId = useId();
  const composing = useRef(false);
  const skipBlur = useRef(false);
  const pending =
    status === "checking" || status === "validating" || status === "saving";

  return (
    <div
      style={{ width: "100%", display: "flex", alignItems: "center", gap: 8 }}
    >
      <select
        autoFocus
        aria-label="Priority"
        aria-busy={pending || undefined}
        aria-disabled={pending || undefined}
        aria-invalid={error ? true : undefined}
        aria-errormessage={error ? errorId : undefined}
        aria-describedby={error ? errorId : undefined}
        value={String(draft ?? "")}
        style={{ width: 84, flexShrink: 0 }}
        // A select has no readOnly. Keep it focusable while guarding changes.
        onPointerDown={(event) => {
          if (pending) event.preventDefault();
        }}
        onChange={(event) => {
          if (!pending) {
            skipBlur.current = false;
            setDraft(event.target.value);
          }
        }}
        onCompositionStart={() => {
          composing.current = true;
        }}
        onCompositionEnd={() => {
          composing.current = false;
        }}
        onKeyDown={(event) => {
          event.stopPropagation();
          if (
            composing.current ||
            event.nativeEvent.isComposing ||
            event.nativeEvent.keyCode === 229
          )
            return;
          if (pending) {
            event.preventDefault();
          } else if (event.key === "Escape") {
            event.preventDefault();
            skipBlur.current = true;
            cancel();
          } else if (event.key === "Enter" || event.key === "Tab") {
            event.preventDefault();
            skipBlur.current = true;
            commit(
              event.key === "Tab"
                ? event.shiftKey
                  ? "left"
                  : "right"
                : event.shiftKey
                  ? "up"
                  : "down",
            );
          }
          // Arrow keys remain native option navigation; they never commit.
        }}
        onBlur={() => {
          if (!skipBlur.current && status === "editing") commit();
          skipBlur.current = false;
        }}
      >
        <option value="1">Low</option>
        <option value="2">Medium</option>
        <option value="3">High</option>
      </select>
      {pending && (
        <small role="status">
          {status === "checking"
            ? "Checking…"
            : status === "validating"
              ? "Validating…"
              : "Saving…"}
        </small>
      )}
      {/* renderEditor replaces the built-in error UI, so render it here. */}
      {error && (
        <small
          id={errorId}
          role="alert"
          style={{
            whiteSpace: "nowrap",
            color: "var(--pretable-text-error, #b42318)",
          }}
        >
          {error}
        </small>
      )}
    </div>
  );
}
```

```tsx columns.tsx
import type { PretableColumn } from "@pretable/react";

import type { Task } from "./data";
import { PriorityEditor } from "./PriorityEditor";

const PRIORITY_LABEL: Record<number, string> = {
  1: "Low",
  2: "Medium",
  3: "High",
};

export const columns: PretableColumn<Task>[] = [
  { id: "title", header: "Title", editable: true, widthPx: 220 },
  {
    id: "priority",
    header: "Priority",
    editable: true,
    widthPx: 360,
    render: ({ row }) => PRIORITY_LABEL[row.priority] ?? String(row.priority),
    // The stored value is a number; a native <select> only ever hands back
    // strings on change, so formatEditValue seeds the draft as a string and
    // parseEditValue converts it back on commit.
    formatEditValue: (value) => String(value),
    parseEditValue: (raw) => Number(raw),
    renderEditor: (input) => <PriorityEditor {...input} />,
  },
];
```

```ts data.ts
export interface Task {
  id: string;
  title: string;
  // 1 = Low, 2 = Medium, 3 = High.
  priority: number;
}

export const tasks: Task[] = [
  { id: "t1", title: "Draft proposal", priority: 2 },
  { id: "t2", title: "Review PR #482", priority: 3 },
  { id: "t3", title: "Update changelog", priority: 1 },
  { id: "t4", title: "Fix flaky test", priority: 3 },
];
```
