Attributes UX: Key (type) labels, filter, Overlay without field target.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

CE-WP-0010: rename form vocabulary in user-facing strings to attributes,
show type next to keys, filter attributes at ≥3 characters, and allow
card→highlight guide lines when no attribute is focused.
This commit is contained in:
tegwick 2026-07-30 19:41:41 +02:00
parent c07e369bdb
commit 32c5a2ede4
5 changed files with 137 additions and 53 deletions

View file

@ -1,5 +1,6 @@
/**
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
* Shared key + type editor for add/edit attribute flows
* (CE-WP-0007-T10/T11; CE-WP-0010 attributes vocabulary).
* Styled to match EvidenceFormBody / InlineCaptureForm.
*/
@ -48,7 +49,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
Field label
Attribute key
</label>
<input
id={`${p.testidPrefix}-label`}
@ -59,7 +60,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
style={inputStyle}
/>
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
Field type
Attribute type
</label>
<select
id={`${p.testidPrefix}-type`}

View file

@ -26,7 +26,7 @@ import {
const SCHEMA: FormSchema = {
id: "demo",
title: "Demo form",
title: "Attributes",
fields: [
{ type: "textarea", id: "summary", label: "Summary" },
{ type: "date", id: "deadline", label: "Deadline" },
@ -59,11 +59,12 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
cleanup();
});
it("renders each schema field with its label", () => {
it("renders each schema field with key (type) labels", () => {
renderWithProviders({ schema: SCHEMA });
expect(screen.getByLabelText("Summary")).toBeTruthy();
expect(screen.getByLabelText("Deadline")).toBeTruthy();
expect(screen.getByLabelText("Amount")).toBeTruthy();
expect(screen.getByLabelText(/Summary \(textarea\)/)).toBeTruthy();
expect(screen.getByLabelText(/Deadline \(date\)/)).toBeTruthy();
expect(screen.getByLabelText(/Amount \(text\)/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
});
it("registers each field with the rect registry as kind=field", () => {
@ -79,7 +80,7 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
const user = userEvent.setup();
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
cleanupCtx = () => ctxValue.observer.disconnect();
await user.click(screen.getByLabelText("Summary"));
await user.click(screen.getByLabelText(/Summary/));
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
expect(fieldEvents).toHaveLength(1);
expect(fieldEvents[0]).toMatchObject({
@ -95,13 +96,29 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
onValueChange: (id, value) => changes.push([id, value]),
});
cleanupCtx = () => ctxValue.observer.disconnect();
await user.type(screen.getByLabelText("Amount"), "42");
await user.type(screen.getByLabelText(/Amount/), "42");
expect(changes).toEqual([
["amount", "4"],
["amount", "2"],
]);
});
it("filters attributes when the query has 3+ characters", async () => {
const user = userEvent.setup();
const { ctxValue } = renderWithProviders({
schema: SCHEMA,
values: { amount: "1500 EUR" },
});
cleanupCtx = () => ctxValue.observer.disconnect();
const filter = screen.getByTestId("attributes-filter");
await user.type(filter, "am");
expect(document.querySelector('[data-field-id="summary"]')).not.toBeNull();
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
await user.type(filter, "o"); // "amo" — matches Amount
expect(document.querySelector('[data-field-id="summary"]')).toBeNull();
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
});
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
const { ctxValue } = renderWithProviders({
schema: SCHEMA,

View file

@ -1,15 +1,23 @@
/**
* FormRenderer renders a FormSchema as a small evidence-backed form.
* FormRenderer renders a FormSchema as evidence-backed attributes.
*
* 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.
* Each attribute registers itself with the rect registry under
* `kind="field"` and the field's `id`, so the SVG visual guide can
* draw curves from the active attribute to its linked evidence card and
* on to the source highlight.
*
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
* CE-WP-0007-T10/T11: add/edit flows use FieldDefinitionForm.
* CE-WP-0010: user-facing "Attributes" vocabulary, `Key (type)` labels,
* and a 3-character filter next to the caption.
*/
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
import {
useMemo,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} from "react";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
@ -17,6 +25,8 @@ import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
import { useActiveState, type ActiveState } from "./state/active";
import { useRegisterRect } from "./visual-guide/react-hooks";
const FILTER_MIN_CHARS = 3;
function isFieldActive(state: ActiveState, fieldId: string): boolean {
return (
state.activeTarget?.targetType === "form-field" &&
@ -24,6 +34,18 @@ function isFieldActive(state: ActiveState, fieldId: string): boolean {
);
}
/** Case-insensitive substring match; empty / short queries match everything. */
export function matchesTextFilter(
query: string,
parts: readonly (string | undefined | null)[],
minChars = FILTER_MIN_CHARS,
): boolean {
const q = query.trim();
if (q.length < minChars) return true;
const needle = q.toLowerCase();
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
}
export interface FormFieldSchema {
readonly type: "text" | "textarea" | "date";
readonly id: string;
@ -113,8 +135,8 @@ function FieldRow({
onChangeType={onChangeEditType}
onSave={onSaveEdit}
onCancel={onCancelEdit}
saveLabel="Save field"
badge="Editing field"
saveLabel="Save attribute"
badge="Editing attribute"
testidPrefix={`field-edit-${field.id}`}
/>
</div>
@ -147,9 +169,9 @@ function FieldRow({
>
<button
type="button"
aria-label={`Edit field ${field.label}`}
aria-label={`Edit ${field.id}`}
data-testid={`field-edit-toggle-${field.id}`}
title="Edit field label and type"
title="Edit attribute key and type"
onClick={(e) => {
e.stopPropagation();
onBeginEdit();
@ -174,7 +196,8 @@ function FieldRow({
paddingRight: 28,
}}
>
{field.label}
{field.label}{" "}
<span style={{ fontWeight: 400, color: "#666" }}>({field.type})</span>
{linkCount > 0 ? (
<span
data-testid={`field-${field.id}-chip`}
@ -218,10 +241,11 @@ export function FormRenderer({
onCancelFieldEdit,
}: FormRendererProps) {
const { state, focusTarget } = useActiveState();
const [addLabel, setAddLabel] = useState("New field");
const [addLabel, setAddLabel] = useState("New attribute");
const [addType, setAddType] = useState<FieldType>("text");
const [editLabel, setEditLabel] = useState("");
const [editType, setEditType] = useState<FieldType>("text");
const [filterQuery, setFilterQuery] = useState("");
const handleFocus = (fieldId: string) => {
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
@ -234,12 +258,21 @@ export function FormRenderer({
onBeginEditField?.(field.id);
};
const visibleFields = useMemo(
() =>
schema.fields.filter((field) =>
matchesTextFilter(filterQuery, [
field.label,
field.type,
field.id,
values?.[field.id],
]),
),
[schema.fields, filterQuery, values],
);
return (
<form
data-form-id={schema.id}
style={{ padding: 12 }}
onSubmit={(e) => e.preventDefault()}
>
<div data-form-id={schema.id} style={{ padding: 12 }} role="group" aria-label="Attributes">
<div
style={{
display: "flex",
@ -247,16 +280,41 @@ export function FormRenderer({
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
flexWrap: "wrap",
}}
>
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
<h2
style={{
fontSize: 14,
margin: 0,
fontFamily: "system-ui, sans-serif",
flex: "0 0 auto",
}}
>
{schema.title}
</h2>
<input
type="search"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="Filter…"
aria-label="Filter attributes"
data-testid="attributes-filter"
style={{
flex: "1 1 100px",
minWidth: 80,
maxWidth: 160,
fontSize: 12,
padding: "4px 6px",
border: "1px solid #ccc",
borderRadius: 4,
}}
/>
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New field ${schema.fields.length + 1}`);
setAddLabel(`New attribute ${schema.fields.length + 1}`);
setAddType("text");
onRequestAddField?.();
}}
@ -269,7 +327,7 @@ export function FormRenderer({
cursor: "pointer",
}}
>
Add field
Add attribute
</button>
</div>
@ -286,13 +344,22 @@ export function FormRenderer({
})
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add field"
badge="New form field"
saveLabel="Add attribute"
badge="New attribute"
testidPrefix="field-add"
/>
)}
{schema.fields.map((field) => (
{visibleFields.length === 0 && schema.fields.length > 0 && (
<p
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
data-testid="attributes-filter-empty"
>
No matches.
</p>
)}
{visibleFields.map((field) => (
<FieldRow
key={field.id}
field={field}
@ -319,6 +386,6 @@ export function FormRenderer({
onCancelEdit={() => onCancelFieldEdit?.()}
/>
))}
</form>
</div>
);
}
}

View file

@ -2,8 +2,9 @@ export * from "./repos";
export * from "./services";
export * from "./state";
export * from "./visual-guide";
export { FormRenderer } from "./FormRenderer";
export { FormRenderer, matchesTextFilter } from "./FormRenderer";
export type {
FieldDefinitionPatch,
FormFieldSchema,
FormRendererProps,
FormSchema,

View file

@ -11,10 +11,10 @@
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
* up that single tick and React re-renders once per frame.
*
* Active-only: only the currently active triple is drawn. If any leg's
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
* the active annotation yet), that leg is omitted but the other one
* still renders.
* Active-only: draws legs for the active evidence card. Attributecard
* when a Capture attribute is focused; cardhighlight whenever the
* highlight rect is available (Annotate or Capture). Missing legs are
* omitted independently.
*
* MVP-sufficient. Future polish: easing the curve direction by source
* type, animating the transition between active states, dimming
@ -33,14 +33,6 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.bottom };
}
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top };
}
/**
* Build a quadratic bezier from `a` to `b` whose control point bulges
* horizontally between them. The horizontal-bulge style is right for a
@ -72,18 +64,24 @@ export function Overlay({
const version = useRectRegistryVersion();
const paths = useMemo<readonly string[]>(() => {
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
// CE-WP-0010: card → highlight is enough for Annotate mode (no attribute
// target). Field → card still draws when Capture has an active target.
if (!state.activeEvidenceItemId) return [];
const fieldRect = state.activeTarget
? registry.getRect("field", state.activeTarget.targetId)
: null;
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
const highlightRect = state.activeAnnotationId
? registry.getRect("highlight", state.activeAnnotationId)
: null;
const out: string[] = [];
if (fieldRect && cardRect) {
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
// Horizontal layout (Evidence | Attributes): centre-to-centre reads better
// than the old bottom-strip geometry.
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
}
if (cardRect && highlightRect) {
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
}
void version; // memo invalidator
return out;