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,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>
);
}
}