Properties Form
alphav0.1.4Schema-driven read and edit form for typed records — six field types, per-field permissions, sync validation, and a custom renderer slot.
Context
Tier 1 pro-component for the graph-system. Pairs with detail-panel as the inline editing surface for entity properties; useful standalone wherever a settings page or properties drawer needs typed fields without pulling in a full form library. Generic over the entity shape; the host owns the data and persistence; permission resolution is layered (host predicate → field declaration → default editable). Sync-only validation in two layers; async deferred to v0.2.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/properties-formAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/properties-form-fixturesPreview
The PRIORITY_OPTIONS list has 4 levels — see dummy-data.ts for the full schemas.
Demo source
Usage
When to use
Reach for PropertiesForm whenever you have a flat record of typed fields — a task, a settings page, a node-properties drawer — and want a controlled read/edit surface with built-in validation, permissions, and a small custom-renderer escape hatch. It is intentionally generic over T; the host owns the data shape.
Basic example
import {
PropertiesForm,
type PropertiesFormField,
} from "@/components/properties-form";
interface Task {
title: string;
done: boolean;
}
const SCHEMA: ReadonlyArray<PropertiesFormField> = [
{ key: "title", type: "string", label: "Title", required: true },
{ key: "done", type: "boolean", label: "Done" },
];
export function TaskCard() {
const [values, setValues] = useState<Task>({ title: "", done: false });
return (
<PropertiesForm<Task>
schema={SCHEMA}
values={values}
onChange={setValues}
onSubmit={async (next) => ({ ok: true })}
mode="edit"
/>
);
}Field types
string— single-line text viaInput.number— right-aligned monospaced input;onChangesees a JS number when parseable.boolean— read rendersCheck/X; edit renders shadcnSwitch.date— native<input type="date">for v0.1 (shadcnCalendarupgrade is non-breaking, planned for v0.2).select— shadcnSelectdriven byfield.options.textarea— multi-line; preserves whitespace in read mode.
Permissions
Each field resolves to editable / read-only / hidden, in this order:
resolvePermission(field, values)— host predicate, returningundefineddefers.- Declarative
field.permission. - Default
editable.
Read-only fields show field.permissionReason in a tooltip. Hidden fields are omitted from the DOM and from the error summary, but their value is preserved in values — the host owns the shape.
Validation
Two layers, both synchronous in v0.1:
- Per-field —
field.validate(value, allValues)runs on every commit; throws are caught and logged. - Form-level —
validate(values)on the form runs only on submit attempts.
Errors render after submit OR after a field is blurred-with-error. On submit failure, focus moves to the first invalid field. Expensive validators should be wrapped in useMemo or debounced — they run on every keystroke for text inputs.
Imperative handle
const formRef = useRef<PropertiesFormHandle>(null);
// ...
<PropertiesForm ref={formRef} ... />
formRef.current?.isDirty(); // boolean
formRef.current?.markClean(); // snapshot current values as clean
formRef.current?.reset(); // restore last cleanSnapshot
formRef.current?.focusField("title");
const result = await formRef.current?.submit();Custom renderers
Set field.renderer to opt out of built-in rendering. The renderer receives FieldRendererProps: value, onChange, field, allValues, mode, error, disabled, fieldId, errorId. Wire fieldId on your input and errorId via aria-describedby so it participates in the same a11y graph as built-ins.
When renderer is set, field.type is advisory only — properties-form does NOT validate that value matches the declared type.
Schema reference stability
Inline schema={[...]} rebuilds field objects on every render and invalidates internal memoization. Hoist to module scope or wrap with useMemo:
const SCHEMA = [/* ... */] satisfies PropertiesFormField[];
<PropertiesForm schema={SCHEMA} ... />
// or, when derived:
const schema = useMemo(() => buildSchema(node), [node]);In-repo, the React Compiler memoizes inline literals at the call site. The two patterns above matter most for the eventual NPM extraction where consumers may not have the Compiler enabled.
What ships in v0.2+
- Async validation hook.
- Conditional
visiblepredicate (sibling ofpermission). - Sections / fieldsets and column layouts.
- shadcn
Calendarupgrade for the date field. - Slot-able
submitActionswith localized defaults.
Features
- Six built-in field types — string, number, boolean, date, select, textarea
- Three-state permissions per field — editable / read-only / hidden
- Layered permission resolver (host predicate → declarative → default)
- Sync per-field + form-level validation; first-error focus on submit failure
- Counter-based dirty tracking with markClean / reset / isDirty
- Async onSubmit with 200ms-delayed spinner and aria-busy
- Custom renderer slot for non-built-in field types
- Imperative handle (submit / reset / markClean / isDirty / focusField)
- ARIA-complete: label, aria-required / -invalid / -describedby, error summary