Attributes UX: Key (type) labels, filter, Overlay without field target.
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:
parent
c07e369bdb
commit
32c5a2ede4
5 changed files with 137 additions and 53 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue