Extract binder package from citation-evidence (EBIND-WP-0001)
Extracts citation-evidence/src/binder/ into this repo as the standalone @citation-evidence/evidence-binder package: headless binding service + in-memory link repo, active-state machine, the SharedContracts §7 rect-registry contract (registry, change pumps, hooks, SVG overlay), and the target-neutral reference FormRenderer. - toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint); imports rewritten from @shared/@engine aliases to the engine's @citation-evidence/engine package specifiers - dependency boundary (engine + anchor only; no source/work/umbrella) enforced via eslint no-restricted-imports - docs: extraction inventory + contract deltas, ADR-0001 (reference UI kept as supported exports), refreshed README/SCOPE/INTENT, populated capabilities index - typecheck + lint green, 37 tests passing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87d4eb3006
commit
a10f080f12
36 changed files with 6643 additions and 170 deletions
324
src/FormRenderer.tsx
Normal file
324
src/FormRenderer.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* FormRenderer — renders a FormSchema as a small evidence-backed form.
|
||||
*
|
||||
* Each field registers itself with the rect registry under
|
||||
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
|
||||
* draw curves from the active field to its linked evidence card and on
|
||||
* to the source highlight.
|
||||
*
|
||||
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
|
||||
*/
|
||||
|
||||
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
|
||||
|
||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||
|
||||
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
||||
import { useActiveState, type ActiveState } from "./state/active";
|
||||
import { useRegisterRect } from "./visual-guide/react-hooks";
|
||||
|
||||
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
||||
return (
|
||||
state.activeTarget?.targetType === "form-field" &&
|
||||
state.activeTarget?.targetId === fieldId
|
||||
);
|
||||
}
|
||||
|
||||
export interface FormFieldSchema {
|
||||
readonly type: "text" | "textarea" | "date";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface FormSchema {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly fields: readonly FormFieldSchema[];
|
||||
}
|
||||
|
||||
export interface FieldDefinitionPatch {
|
||||
readonly label: string;
|
||||
readonly type: FieldType;
|
||||
}
|
||||
|
||||
export interface FormRendererProps {
|
||||
readonly schema: FormSchema;
|
||||
readonly values?: Readonly<Record<string, string>>;
|
||||
readonly onValueChange?: (fieldId: string, value: string) => void;
|
||||
readonly linkCounts?: Readonly<Record<string, number>>;
|
||||
readonly linkHints?: Readonly<Record<string, string>>;
|
||||
readonly showAddFieldForm?: boolean;
|
||||
readonly onRequestAddField?: () => void;
|
||||
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
|
||||
readonly onCancelAddField?: () => void;
|
||||
readonly editingFieldId?: string | null;
|
||||
readonly onBeginEditField?: (fieldId: string) => void;
|
||||
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
|
||||
readonly onCancelFieldEdit?: () => void;
|
||||
}
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
padding: "2px 6px",
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
function FieldRow({
|
||||
field,
|
||||
value,
|
||||
linkCount,
|
||||
linkHint,
|
||||
isActive,
|
||||
isEditing,
|
||||
editLabel,
|
||||
editType,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBeginEdit,
|
||||
onChangeEditLabel,
|
||||
onChangeEditType,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
linkCount: number;
|
||||
linkHint?: string;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
editLabel: string;
|
||||
editType: FieldType;
|
||||
onChange: (next: string) => void;
|
||||
onFocus: () => void;
|
||||
onBeginEdit: () => void;
|
||||
onChangeEditLabel: (next: string) => void;
|
||||
onChangeEditType: (next: FieldType) => void;
|
||||
onSaveEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRegisterRect("field", field.id, ref);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
|
||||
<FieldDefinitionForm
|
||||
label={editLabel}
|
||||
type={editType}
|
||||
onChangeLabel={onChangeEditLabel}
|
||||
onChangeType={onChangeEditType}
|
||||
onSave={onSaveEdit}
|
||||
onCancel={onCancelEdit}
|
||||
saveLabel="Save field"
|
||||
badge="Editing field"
|
||||
testidPrefix={`field-edit-${field.id}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedProps = {
|
||||
id: `field-${field.id}`,
|
||||
value,
|
||||
onFocus,
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange(e.target.value),
|
||||
style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-field-id={field.id}
|
||||
data-link-count={String(linkCount)}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
style={{
|
||||
position: "relative",
|
||||
marginBottom: 12,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
background: isActive ? "#e8f0ff" : "transparent",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit field ${field.label}`}
|
||||
data-testid={`field-edit-toggle-${field.id}`}
|
||||
title="Edit field label and type"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBeginEdit();
|
||||
}}
|
||||
style={{
|
||||
...iconButtonStyle,
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<label
|
||||
htmlFor={sharedProps.id}
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
paddingRight: 28,
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
{linkCount > 0 ? (
|
||||
<span
|
||||
data-testid={`field-${field.id}-chip`}
|
||||
title={linkHint}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
background: "#e7f0ff",
|
||||
color: "#0050b3",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{linkCount} evidence
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea rows={2} {...sharedProps} />
|
||||
) : (
|
||||
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormRenderer({
|
||||
schema,
|
||||
values,
|
||||
onValueChange,
|
||||
linkCounts,
|
||||
linkHints,
|
||||
showAddFieldForm,
|
||||
onRequestAddField,
|
||||
onConfirmAddField,
|
||||
onCancelAddField,
|
||||
editingFieldId,
|
||||
onBeginEditField,
|
||||
onSaveFieldEdit,
|
||||
onCancelFieldEdit,
|
||||
}: FormRendererProps) {
|
||||
const { state, focusTarget } = useActiveState();
|
||||
const [addLabel, setAddLabel] = useState("New field");
|
||||
const [addType, setAddType] = useState<FieldType>("text");
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editType, setEditType] = useState<FieldType>("text");
|
||||
|
||||
const handleFocus = (fieldId: string) => {
|
||||
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||
focusTarget(target);
|
||||
};
|
||||
|
||||
const beginEdit = (field: FormFieldSchema) => {
|
||||
setEditLabel(field.label);
|
||||
setEditType(field.type);
|
||||
onBeginEditField?.(field.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-form-id={schema.id}
|
||||
style={{ padding: 12 }}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||
{schema.title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-field-button"
|
||||
onClick={() => {
|
||||
setAddLabel(`New field ${schema.fields.length + 1}`);
|
||||
setAddType("text");
|
||||
onRequestAddField?.();
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 4,
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Add field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddFieldForm && (
|
||||
<FieldDefinitionForm
|
||||
label={addLabel}
|
||||
type={addType}
|
||||
onChangeLabel={setAddLabel}
|
||||
onChangeType={setAddType}
|
||||
onSave={() =>
|
||||
onConfirmAddField?.({
|
||||
label: addLabel.trim(),
|
||||
type: addType,
|
||||
})
|
||||
}
|
||||
onCancel={() => onCancelAddField?.()}
|
||||
saveLabel="Add field"
|
||||
badge="New form field"
|
||||
testidPrefix="field-add"
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values?.[field.id] ?? ""}
|
||||
linkCount={linkCounts?.[field.id] ?? 0}
|
||||
{...(linkHints?.[field.id] != null
|
||||
? { linkHint: linkHints[field.id] }
|
||||
: {})}
|
||||
isActive={isFieldActive(state, field.id)}
|
||||
isEditing={editingFieldId === field.id}
|
||||
editLabel={editLabel}
|
||||
editType={editType}
|
||||
onChange={(next) => onValueChange?.(field.id, next)}
|
||||
onFocus={() => handleFocus(field.id)}
|
||||
onBeginEdit={() => beginEdit(field)}
|
||||
onChangeEditLabel={setEditLabel}
|
||||
onChangeEditType={setEditType}
|
||||
onSaveEdit={() =>
|
||||
onSaveFieldEdit?.(field.id, {
|
||||
label: editLabel.trim(),
|
||||
type: editType,
|
||||
})
|
||||
}
|
||||
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue