Skip to content
ilinxa/pro-ui

Properties Form

alphav0.1.5

Schema-driven read and edit form for typed records — six field types, per-field permissions, sync validation, and a custom renderer slot.

Category: FormsUpdated: 2026-08-18Created: 2026-04-29Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/properties-form

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/properties-form-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Short description visible in lists.

Migrate auth middleware to v2 API
In progress
High
rina@ilinxa.dev
6
2026-05-12

Marks the task closed once done.

No
Compatible with both providers; needs review by the security team before rollout. Follow the migration checklist in the runbook.

The PRIORITY_OPTIONS list has 4 levels — see dummy-data.ts for the full schemas.

Demo source

demo.tsxtsx
"use client"; import { useCallback, useMemo, useRef, useState } from "react";import { Plus, X } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { PropertiesForm } from "./properties-form";import {  PRIORITY_OPTIONS,  TASK_BASELINE,  TASK_SCHEMA,  TASK_SCHEMA_MIXED,  TASK_SCHEMA_VALIDATED,  type TaskValues,} from "./dummy-data";import type {  FieldRendererProps,  PropertiesFormField,  SubmitResult,} from "./types"; function ReadDemo() {  const [values, setValues] = useState<TaskValues>(TASK_BASELINE);  return (    <div className="rounded-md border border-border bg-card p-5">      <PropertiesForm<TaskValues>        schema={TASK_SCHEMA}        values={values}        onChange={setValues}        mode="read"        ariaLabel="Task — read mode"      />    </div>  );} function EditDemo() {  const [values, setValues] = useState<TaskValues>(TASK_BASELINE);  const [savedAt, setSavedAt] = useState<string | null>(null);   const onSubmit = useCallback(async (): Promise<SubmitResult> => {    await new Promise((r) => setTimeout(r, 600));    setSavedAt(new Date().toLocaleTimeString());    return { ok: true };  }, []);   return (    <div className="flex flex-col gap-3 rounded-md border border-border bg-card p-5">      <PropertiesForm<TaskValues>        schema={TASK_SCHEMA}        values={values}        onChange={setValues}        onSubmit={onSubmit}        mode="edit"        ariaLabel="Task — edit mode"      />      {savedAt ? (        <p className="text-xs text-muted-foreground">          Last saved at <span className="font-mono">{savedAt}</span>        </p>      ) : null}    </div>  );} function MixedPermissionsDemo() {  const [values, setValues] = useState<TaskValues>(TASK_BASELINE);  return (    <div className="flex flex-col gap-3 rounded-md border border-border bg-card p-5">      <p className="text-xs text-muted-foreground">        Status &amp; Assignee are read-only with hover tooltips. Completed is hidden via permission.      </p>      <PropertiesForm<TaskValues>        schema={TASK_SCHEMA_MIXED}        values={values}        onChange={setValues}        mode="edit"        ariaLabel="Task — mixed permissions"      />    </div>  );} function ValidationDemo() {  const [values, setValues] = useState<TaskValues>({    ...TASK_BASELINE,    title: "tiny",    estimatedHours: -3,    assignee: "no-at-sign",  });  const [submitOutcome, setSubmitOutcome] = useState<string | null>(null);   const formValidate = useCallback((vs: TaskValues) => {    const errors: Record<string, string> = {};    if (vs.priority === "urgent" && vs.status === "todo") {      errors.priority = "Urgent tasks shouldn't sit in To do — pick a status.";    }    return Object.keys(errors).length > 0 ? errors : undefined;  }, []);   const onSubmit = useCallback(async (vs: TaskValues): Promise<SubmitResult> => {    await new Promise((r) => setTimeout(r, 350));    setSubmitOutcome(`Submitted: ${vs.title}`);    return { ok: true };  }, []);   return (    <div className="flex flex-col gap-3 rounded-md border border-border bg-card p-5">      <p className="text-xs text-muted-foreground">        Per-field validators (length, numeric range, format) plus a form-level cross-field rule.      </p>      <PropertiesForm<TaskValues>        schema={TASK_SCHEMA_VALIDATED}        values={values}        onChange={setValues}        validate={formValidate}        onSubmit={onSubmit}        mode="edit"        ariaLabel="Task — validation"      />      {submitOutcome ? (        <p className="text-xs text-muted-foreground">{submitOutcome}</p>      ) : null}    </div>  );} function TagsRenderer({  value,  onChange,  field,  disabled,  fieldId,  errorId,  error,}: FieldRendererProps) {  const tags = useMemo<string[]>(    () =>      Array.isArray(value)        ? (value as unknown[]).filter(            (t): t is string => typeof t === "string",          )        : [],    [value],  );  const [draft, setDraft] = useState("");  const inputRef = useRef<HTMLInputElement>(null);   const addTag = useCallback(() => {    const trimmed = draft.trim();    if (!trimmed) return;    if (tags.includes(trimmed)) {      setDraft("");      return;    }    onChange([...tags, trimmed]);    setDraft("");    inputRef.current?.focus();  }, [draft, tags, onChange]);   const removeTag = useCallback(    (t: string) => onChange(tags.filter((x) => x !== t)),    [tags, onChange],  );   return (    <div      aria-describedby={error ? errorId : undefined}      className="flex flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent p-1.5 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"    >      {tags.map((tag) => (        <Badge          key={tag}          variant="secondary"          className="gap-1 pr-1 font-mono text-xs"        >          {tag}          <button            type="button"            aria-label={`Remove ${tag}`}            onClick={() => removeTag(tag)}            disabled={disabled}            className="rounded-sm p-0.5 hover:bg-foreground/10 disabled:opacity-50"          >            <X aria-hidden="true" className="size-3" />          </button>        </Badge>      ))}      <input        ref={inputRef}        id={fieldId}        type="text"        value={draft}        disabled={disabled}        placeholder={tags.length === 0 ? field.placeholder ?? "Add a tag…" : ""}        onChange={(e) => setDraft(e.target.value)}        onKeyDown={(e) => {          if (e.key === "Enter" || e.key === ",") {            e.preventDefault();            addTag();          } else if (e.key === "Backspace" && draft.length === 0 && tags.length > 0) {            e.preventDefault();            onChange(tags.slice(0, -1));          }        }}        className="min-w-24 flex-1 bg-transparent px-1.5 py-0.5 text-sm outline-none placeholder:text-muted-foreground"      />      <Button        type="button"        size="sm"        variant="ghost"        onClick={addTag}        disabled={disabled || draft.trim().length === 0}        className="ml-auto"      >        <Plus aria-hidden="true" className="size-3" />        Add      </Button>    </div>  );} interface TaggedTaskValues extends TaskValues {  tags: string[];} function CustomRendererDemo() {  const [values, setValues] = useState<TaggedTaskValues>({    ...TASK_BASELINE,    tags: ["security", "auth", "v2"],  });   const schema = useMemo<ReadonlyArray<PropertiesFormField>>(    () => [      ...TASK_SCHEMA,      {        key: "tags",        type: "string",        label: "Tags",        description: "Comma or Enter to commit. Backspace removes the last.",        renderer: TagsRenderer,        placeholder: "Add a tag…",      },    ],    [],  );   return (    <div className="rounded-md border border-border bg-card p-5">      <PropertiesForm<TaggedTaskValues>        schema={schema}        values={values}        onChange={setValues}        mode="edit"        ariaLabel="Task — custom tags renderer"      />    </div>  );} export default function PropertiesFormDemo() {  return (    <div className="flex flex-col gap-4">      <Tabs defaultValue="read">        <SwipeTabsList>          <TabsTrigger value="read">Read mode</TabsTrigger>          <TabsTrigger value="edit">Edit + submit</TabsTrigger>          <TabsTrigger value="mixed">Mixed permissions</TabsTrigger>          <TabsTrigger value="validation">Validation</TabsTrigger>          <TabsTrigger value="custom">Custom renderer</TabsTrigger>        </SwipeTabsList>        <TabsContent value="read" className="mt-4">          <ReadDemo />        </TabsContent>        <TabsContent value="edit" className="mt-4">          <EditDemo />        </TabsContent>        <TabsContent value="mixed" className="mt-4">          <MixedPermissionsDemo />        </TabsContent>        <TabsContent value="validation" className="mt-4">          <ValidationDemo />        </TabsContent>        <TabsContent value="custom" className="mt-4">          <CustomRendererDemo />        </TabsContent>      </Tabs>      <p className="text-xs text-muted-foreground">        The PRIORITY_OPTIONS list has {PRIORITY_OPTIONS.length} levels — see <code>dummy-data.ts</code> for the full schemas.      </p>    </div>  );} 

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 via Input.
  • number — right-aligned monospaced input; onChange sees a JS number when parseable.
  • boolean — read renders Check/X; edit renders shadcn Switch.
  • date — native <input type="date"> for v0.1 (shadcn Calendar upgrade is non-breaking, planned for v0.2).
  • select — shadcn Select driven by field.options.
  • textarea — multi-line; preserves whitespace in read mode.

Permissions

Each field resolves to editable / read-only / hidden, in this order:

  1. resolvePermission(field, values) — host predicate, returning undefined defers.
  2. Declarative field.permission.
  3. 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 visible predicate (sibling of permission).
  • Sections / fieldsets and column layouts.
  • shadcn Calendar upgrade for the date field.
  • Slot-able submitActions with 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

Tags

properties-formformschemavalidationgraph-system

Dependencies

shadcn primitives: button, input, select, switch, textarea, tooltip
npm peer deps: lucide-react@^1.11.0