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, formreadOnly, orInlineEdit readOnlyand 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
| Signal | Carries | Default |
|---|---|---|
| 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 fact | On for every readOnly control; no flag |
| Reason (optional) | Why it is locked | Only 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
| Owner | Slot | Why |
|---|---|---|
| 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 control | Cells have no label; expand panel stays table chrome |
| Inline Edit | Trigger 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 scope | What the operator sees |
|---|---|
Field — field.readOnly, Access locked | Control + lock mark |
Row — rowEditable(row) === false | Read text, no marks (row chrome) |
Surface — EditableTable readOnly | Read text, no marks (record chrome) |
Browse — editMode="row" before edit | No 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
| Rule | Detail |
|---|---|
| Navigable, not operable | readOnly 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-only | Forms / expand panel / Inline Edit: static text + aria-describedby. Table cells: focus Popover + sr-only link |
| Signifiers | Forms 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/hideLockprop —readOnly⇒ mark - Never gate the mark on
lockReason - No lock on
readPretty, display columns, or Accessmaskedvalues - No lock standing in for
disabled(dependency not met stays grey) - The lock never means permission or security — read-denied goes through Access
masked→readPretty - 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
| API | Shape | Notes |
|---|---|---|
FormField / FormItem lockReason | ReactNode | Static text under the control |
InlineEdit lockReason | ReactNode | Static text under the value |
EditableColumn.field.lockReason | ReactNode | ((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).
"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.
"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’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)
"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)
"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.
Unlocks after the contract is countersigned
"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.
Related
| Topic | Page |
|---|---|
| Pattern vocabulary | Form (Formily) |
| Approver inbox Display until Unlock | Form Validation — Mixed / Approver Lock |
| Empty pretty vs blank locked control | Empty 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).