f-ui
Design

Field Lock Affordance

How locked fill-in fields show a lock mark and optional reason across forms, Editable Table, and Inline Edit — without looking like system facts or security.

Operators scan one resource and must separate three things in one pass: fields they can change now, fill-in fields locked this session, and values that were never fill-in fields. This page locks the lock mark and optional reason across every surface that owns field chrome.

Pattern vocabulary (editable / readOnly / readPretty / disabled) stays on Form (Formily) and Form Validation — Mixed / Approver Lock. This page is the chrome layer on top of readOnly.

When To Use

  • You set field.readOnly, form readOnly, or InlineEdit readOnly and need the lock to appear without inventing a prop.
  • You need to explain why a field is locked (lockReason) without looking invalid.
  • You are choosing between a locked control (readOnly) and bare display (readPretty) on an Edit page.
  • You own an Editable Table that is wholly read-only, or some rows are not editable — and you must not paint a lock wall.

Three Signals

SignalCarriesDefault
Shape (primary)Field box = fill-in field (writable or locked). Bare text = system fact.Already shipped
Lock mark (secondary)This fill-in field is locked now — not a system factOn for every readOnly control; no flag
Reason (optional)Why it is lockedOnly when the host passes lockReason

pattern alone decides operability. The lock is chrome over pattern === "readOnly" — never a fifth pattern and never a capability.

Where a control mounts only after activation (editMode="row" browse cell, InlineEdit read mode), signal 1 is the activation affordance (clickable cell / pencil) rather than the box. The lock is the negative of that affordance.

Placement — One Slot Per Owner

OwnerSlotWhy
FormItem (page / panel / drawer)Label mark slot (same place as the required star)Every archetype has a label, including radio groups
Editable Table (cells and expand panel)Trailing after the controlCells have no label; expand panel stays table chrome
Inline EditTrigger slot (pencil vs lock)Salesforce-style pencil / lock pair

A FormItem with no visible label renders no mark — give the field a label if the mark matters. Never render two marks on one field (label and trailing).

Scope — Field Lock Vs Record Lock

Lock scopeWhat the operator sees
Fieldfield.readOnly, Access lockedControl + lock mark
RowrowEditable(row) === falseRead text, no marks (row chrome)
SurfaceEditableTable readOnlyRead text, no marks (record chrome)
BrowseeditMode="row" before editNo mark; it appears when the row enters edit

An all-readOnly table or a locked row must not paint one lock per cell. Record-level state is object chrome, not a lock wall.

Focus And Signifiers

RuleDetail
Navigable, not operablereadOnly stays in the tab order, is never disabled, and shows a quiet focus ring — not the editable ring, and not “no ring”
Reason is never hover-onlyForms / expand panel / Inline Edit: static text + aria-describedby. Table cells: focus Popover + sr-only link
SignifiersForms and Inline Edit keep muted chevron / calendar / clock. Table cells hide those glyphs when locked — the trailing lock already says “not interactive”

Hard Bans

  • No lockMark / showLock / hideLock prop — readOnly ⇒ mark
  • Never gate the mark on lockReason
  • No lock on readPretty, display columns, or Access masked values
  • No lock standing in for disabled (dependency not met stays grey)
  • The lock never means permission or security — read-denied goes through Access maskedreadPretty
  • Never put the lock inside a control shell; one glyph component, three renderers
  • Never mute a signifier and show a lock in the same table cell

API Surface

APIShapeNotes
FormField / FormItem lockReasonReactNodeStatic text under the control
InlineEdit lockReasonReactNodeStatic text under the value
EditableColumn.field.lockReasonReactNode | ((row) => ReactNode)Focus Popover in cells; static text in expand panel

The mark itself has no prop. It is derived from pattern, like the rest of readOnly chrome.

Examples

Form — Mixed Pattern

Edit session: system fact (readPretty), locked fill-in fields (readOnly + lock on the label + lockReason), and a writable field.

Try it: Tab to SKU and Region. Confirm each shows a muted lock next to the label and reason text under the control. Confirm Created has no lock (pretty, not a control).

Aug 1, 2026

System timestamp — display only

Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome.

Locked for this Edit session.

Read-only

Set by the approval route — reopen the request to change it.

Writable this session

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ReviewValues {
  createdAt: Date | null;
  sku: string;
  region: string;
  comment: string;
}

export function FormilyMixedPatternDemo() {
  const form = useMemo(
    () =>
      createForm<ReviewValues>({
        initialValues: {
          createdAt: new Date(2026, 7, 1),
          sku: "SKU-104",
          region: "apac",
          comment: "",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ReviewValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="createdAt"
          label="Created"
          description="System timestamp — display only"
          readPretty
          kind="date"
        />
        <FormField
          name="sku"
          label="SKU"
          description="Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome."
          readOnly
          lockReason="Locked for this Edit session."
          component={[Input]}
        />
        <FormField
          name="region"
          label="Region"
          readOnly
          kind="radio"
          lockReason="Set by the approval route — reopen the request to change it."
          componentProps={{
            options: [
              { label: "APAC", value: "apac" },
              { label: "EMEA", value: "emea" },
            ],
            orientation: "horizontal",
          }}
        />
        <FormField
          name="comment"
          label="Comment"
          description="Writable this session"
          component={[Textarea, { placeholder: "Approver note" }]}
        />
        <FormActions>
          <Button type="submit">
            Submit review
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

→ Full pattern table: Form (Formily) — When To Use.

Editable Table — Lock Reason

Field-scoped locks keep a control and a trailing mark. Reason appears on focus — never as error chrome.

Try it: Tab into a locked SKU or Ship via cell. Confirm the muted lock, the reason popover, and no red ring.

Locked cells stay muted, never red, and Qty stays writable so the difference is scannable in one pass. Focus a locked control to read why. Ship via is a locked composite: the lock takes the chevron’s place instead of crowding beside it.

SKU
Ship via
Qty
Actions
You do not have permission to edit this line
Read-only
Carrier is set by the shipping plan
Locked once the line is posted to the ledger
Read-only
Carrier is set by the shipping plan
Unlocks after the customer is confirmed
Read-only
Carrier is set by the shipping plan
"use client";

import { useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  shipVia: string;
  qty: number;
  lock: "permission" | "posted" | "dependency";
};

// Field-scoped reasons only. "Another user is editing this row" is a row lock
// (spec L14) and belongs to row chrome, not to a field mark.
const REASON: Record<Line["lock"], string> = {
  permission: "You do not have permission to edit this line",
  posted: "Locked once the line is posted to the ledger",
  dependency: "Unlocks after the customer is confirmed",
};

const SHIP_VIA = [
  { label: "Air", value: "air" },
  { label: "Ground", value: "ground" },
];

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: {
      component: [Input],
      readOnly: () => true,
      lockReason: (row) => REASON[(row as Line).lock],
    },
  },
  // Composite archetype: the trailing lock replaces the chevron rather than
  // sitting beside it (spec L19).
  shipVia: {
    ...f.text({ label: "Ship via" }),
    size: 110,
    field: {
      component: [Select, { options: SHIP_VIA, surface: "tableCell" }],
      readOnly: () => true,
      lockReason: () => "Carrier is set by the shipping plan",
    },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0 }],
    },
  },
});

export function EditableTableLockReasonDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "a", sku: "SKU-1", shipVia: "air", qty: 2, lock: "permission" },
            { id: "b", sku: "SKU-2", shipVia: "ground", qty: 4, lock: "posted" },
            {
              id: "c",
              sku: "SKU-3",
              shipVia: "air",
              qty: 1,
              lock: "dependency",
            },
          ],
        },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <p className="text-muted-foreground mb-3 text-sm">
        Locked cells stay muted, never red, and Qty stays writable so the
        difference is scannable in one pass. Focus a locked control to read why.
        Ship via is a locked composite: the lock takes the chevron&rsquo;s place
        instead of crowding beside it.
      </p>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        variant="embedded"
      />
    </Form>
  );
}

→ Demo home: Editable Table — Lock Reason.

Editable Table — Field Vs Surface Lock

field.readOnly keeps a Qty control with a mark (editMode="inline" so the mark is visible without entering row edit). Whole-table readOnly is record chrome: read text, no lock wall.

Try it: Compare the two columns — left Qty has a muted lock; right side is bare text with no marks.

Field lock (control + mark)

Actions
39.80
Sum39.80
"use client";

import { isField } from "@formily/core";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";

type Line = {
  id: string;
  sku: string;
  qty: number | null;
  price: number;
  subtotal: number | null;
};

type Values = { lines: Line[] };

function lineNet(qty: number | null, price: number) {
  if (qty == null) return null;
  return qty * price;
}

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 120,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, step: 1 }],
      readOnly: () => true,
    },
  },
  price: {
    ...f.currency({ label: "Price", currency: "USD", measure: "none" }),
    size: 110,
    field: {
      component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
    },
  },
  subtotal: {
    ...f.currency({
      label: "Subtotal",
      currency: "USD",
      measure: "none",
      accessor: (row) => lineNet(row.qty, row.price),
    }),
    size: 120,
    field: {
      component: [
        CurrencyInput,
        { currency: "USD", surface: "tableCell", measure: "none" },
      ],
      readPretty: () => true,
      reactions: (field) => {
        if (!isField(field)) return;
        const qty = field.query(".qty").value() as number | null;
        const price = Number(field.query(".price").value()) || 0;
        field.value = lineNet(qty, price);
      },
    },
    footer: (rows) =>
      formatMoneyForDisplay(
        rows.reduce((sum, row) => sum + (lineNet(row.qty, row.price) ?? 0), 0),
        "USD",
        undefined,
        { measure: "none" },
      ),
  },
});

export function EditableTableMixedPatternDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: {
          lines: [
            {
              id: "l1",
              sku: "SKU-104",
              qty: 2,
              price: 19.9,
              subtotal: 39.8,
            },
            {
              id: "l2",
              sku: "SKU-200",
              qty: null,
              price: 8.5,
              subtotal: null,
            },
          ],
        },
      }),
    [],
  );
  const [saved, setSaved] = useState<Values | null>(null);

  return (
    <Form
      form={form}
      onSubmit={async (values) => {
        setSaved(values);
        toast.success("Submitted successfully");
      }}
    >
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(row) => row.id}
        editMode="inline"
        features={{ sorting: true, footer: true }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
      {saved ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(saved, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Surface lock (read text, no marks)

SKU-001Wrench219.9039.80
SKU-010Socket set189.0089.00
SKU-020Drill bit412.5050.00
SKU-030Tape measure38.7526.25
Sum205.05
"use client";

import { useMemo } from "react";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";

import { ORDER_SEED, lineColumns, type LineItem } from "./editable-table-demo-shared";

export function EditableTableReadonlyDemo() {
  const form = useMemo(
    () =>
      createForm<{ lineItems: LineItem[] }>({
        initialValues: { lineItems: ORDER_SEED.slice(0, 4) },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <EditableTable<LineItem>
        name="lineItems"
        columns={lineColumns}
        getRowId={(r) => r.id}
        readOnly
        features={{ sorting: true, footer: true }}
      />
    </Form>
  );
}

Inline Edit — Locked Vs Disabled

Lock and pencil share one slot. disabled shows neither.

Try it: Focus the locked value. Confirm the lock follows the same affordance as the pencil, and lockReason sits under the value.

Owner
Dana Lin
Contract ref
CTR-4471

Unlocks after the contract is countersigned

Renewal term
12 months
"use client";

import { useState } from "react";

import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { Input } from "@/components/ui/input";

function Row({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-[8rem_1fr] items-center gap-3 py-1">
      <span className="text-muted-foreground text-sm">{label}</span>
      {children}
    </div>
  );
}

export function InlineEditLockDemo() {
  const [owner, setOwner] = useState("Dana Lin");

  return (
    <div className="max-w-lg">
      <Row label="Owner">
        <InlineEdit
          value={owner}
          affordance="always"
          onCommit={({ value }) => setOwner(value)}
          renderDisplay={(value) => <span>{value}</span>}
          renderEditor={({ value, onChange, onKeyDown, onBlur }) => (
            <Input
              aria-label="Owner"
              value={value}
              onChange={(event) => onChange(event.currentTarget.value)}
              onKeyDown={onKeyDown}
              onBlur={onBlur}
            />
          )}
        />
      </Row>

      <Row label="Contract ref">
        <InlineEdit
          value="CTR-4471"
          readOnly
          affordance="always"
          lockReason="Unlocks after the contract is countersigned"
          renderDisplay={(value) => <span>{value}</span>}
          renderEditor={({ value }) => <Input value={value} readOnly />}
        />
      </Row>

      <Row label="Renewal term">
        <InlineEdit
          value="12 months"
          disabled
          affordance="always"
          renderDisplay={(value) => <span>{value}</span>}
          renderEditor={({ value }) => <Input value={value} readOnly />}
        />
      </Row>
    </div>
  );
}

Inline Edit — Locked vs Disabled.

TopicPage
Pattern vocabularyForm (Formily)
Approver inbox Display until UnlockForm Validation — Mixed / Approver Lock
Empty pretty vs blank locked controlEmpty Value Placeholder
Cell value state (error / warning)Object Messaging And Table Chrome

Industry twins: Dynamics / Power Apps Business Rule lock (padlock by the label); Salesforce lightning-datatable read-only icon (in-cell) and record-detail pencil/lock pair; Carbon Read-only states (navigable ≠ operable; grey is disabled).

On this page