fix(ara-projects): align the add-document composer with the table it feeds (#412)

Three complaints, one cause: the composer was a separate block trying to
look like part of the table. It now *is* part of it — a `<tfoot>`, so its
fields sit in the same columns as the rows by construction, and the
table's own headings label them. No second caption set to drift out of
line, and no unlabelled fields either.

Its controls belong to a form declared below the table and are wired to
it by `form=`, because a `<form>` cannot sit between a `<table>` and its
rows; Enter still submits.

It stays distinct: a 4px rule above it, a tinted band, and its own "Add a
document" caption spanning the width.

Also: selects get `pr-8` so the native chevron stops overlapping the
option text, the Stage column widens to `w-52`, and the last column
widens to fit the Add button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 14:24:02 +00:00
parent 5c94a538ed
commit df8e0315cb

View file

@ -44,8 +44,11 @@ const EMPTY_DRAFT: Draft = {
namingRule: "",
};
// `pr-8` keeps the option text clear of the native chevron, which is drawn
// inside the control's right padding — at `px-2` a long stage name runs
// straight under it.
const selectClass =
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400";
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 pr-8 text-sm text-gray-700 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400";
const inputClass =
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 font-mono text-xs text-gray-700 placeholder:font-sans placeholder:text-gray-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50";
@ -67,6 +70,13 @@ export function RequirementsTable({
const collectionUrl = `/api/projects/${projectId}/workstreams/${workstream.workstreamId}/evidence-requirements`;
/**
* Ties the composer's controls to the form element below the table. Derived
* from the workstream rather than generated, so it is the same string on the
* server and in the browser and distinct per workstream.
*/
const addFormId = `evidence-add-${workstream.workstreamId}`;
const refresh = () =>
queryClient.invalidateQueries({ queryKey: evidenceQueryKey(projectId) });
@ -197,12 +207,14 @@ export function RequirementsTable({
<thead>
<tr className="bg-white text-xs uppercase tracking-wide text-gray-400">
<th className="px-5 py-2 font-semibold">Document</th>
<th className="w-48 px-3 py-2 font-semibold">Stage</th>
{/* Wide enough for "Whole workstream" and a stage name, chevron included. */}
<th className="w-52 px-3 py-2 font-semibold">Stage</th>
<th className="px-3 py-2 font-semibold">Naming rule</th>
<th className="w-24 px-3 py-2 text-center font-semibold">
Required
</th>
<th className="w-14 px-3 py-2" />
{/* Wide enough for the composer's Add button, not just the bin. */}
<th className="w-24 px-3 py-2" />
</tr>
</thead>
<tbody>
@ -312,22 +324,148 @@ export function RequirementsTable({
</tr>
))}
</tbody>
{/*
The composer lives in the table's own `<tfoot>`, so its fields sit
in the same columns as the rows above by construction no second
set of column captions to go subtly out of line, and the table's
headings label the composer too.
It stays obviously separate: a heavy rule above it, a tinted band,
and its own "Add a document" caption spanning the width.
The controls are associated with the `<form>` below by id rather
than being wrapped in one, because a `<form>` cannot sit between a
`<table>` and its rows. Enter still submits.
*/}
{canManage && (
<tfoot className="bg-gray-50/70" data-testid="evidence-add-form">
<tr>
{/*
The dividing rule hangs off this cell rather than off
`<tfoot>`: in the collapsed border model a row-group border
is the least reliably drawn of the two, and this one spans
the full width anyway.
*/}
<td
colSpan={5}
className="border-t-4 border-gray-100 px-5 pb-1 pt-3"
>
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
<Plus className="h-3.5 w-3.5" />
Add a document
</span>
</td>
</tr>
<tr>
<td className="px-5 py-2">
<select
className={selectClass}
form={addFormId}
value={draft.fileType}
disabled={busy}
aria-label="Document"
data-testid="evidence-add-file-type"
onChange={(event) =>
setDraft({ ...draft, fileType: event.target.value })
}
>
<option value="">Choose a document</option>
{FILE_TYPE_GROUPS.map((group) => (
<optgroup key={group.group} label={group.group}>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
</td>
<td className="px-3 py-2">
<select
className={selectClass}
form={addFormId}
value={draft.projectWorkstreamStageId}
disabled={busy || !hasStages}
aria-label="Stage"
data-testid="evidence-add-stage"
onChange={(event) =>
setDraft({
...draft,
projectWorkstreamStageId: event.target.value,
})
}
>
<option value={NO_STAGE}>
{hasStages ? "Whole workstream" : "No stages yet"}
</option>
{workstream.stages.map((stage) => (
<option key={stage.id} value={stage.id}>
{stage.name}
</option>
))}
</select>
</td>
<td className="px-3 py-2">
<input
type="text"
className={inputClass}
form={addFormId}
value={draft.namingRule}
disabled={busy}
placeholder="e.g. [AssetRef]_[WORef]_Cert"
aria-label="Naming rule"
data-testid="evidence-add-naming-rule"
onChange={(event) =>
setDraft({ ...draft, namingRule: event.target.value })
}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
form={addFormId}
checked={draft.required}
disabled={busy}
aria-label="Required"
data-testid="evidence-add-required"
onChange={(event) =>
setDraft({ ...draft, required: event.target.checked })
}
/>
</td>
<td className="px-3 py-2 text-right">
<Button
type="submit"
size="sm"
form={addFormId}
disabled={busy}
data-testid="evidence-add-submit"
>
Add
</Button>
</td>
</tr>
<tr>
<td colSpan={5} className="px-5 pb-3 text-xs text-gray-400">
{hasStages
? "Leave the stage as “Whole workstream” to expect the document at any point."
: "This workstream has no stages yet, so a requirement applies to the workstream as a whole. Tag it with a stage once the ladder exists."}
</td>
</tr>
</tfoot>
)}
</table>
</div>
{/*
The composer is deliberately *not* a twelfth column-aligned row.
Labelled cells sitting under the table's own headings read as a second
header row two near-identical sets of words, never quite in line,
which is at its worst when the table is empty. So it announces itself
with its own heading and lays its controls out as a form: the fields
carry placeholders and `aria-label`s rather than column captions, and
they are free to size themselves.
The form the composer's controls belong to. It carries the submit
handler and nothing else the fields themselves are up in the table's
footer, pointed here by their `form` attribute.
*/}
{canManage && (
<form
className="border-t-4 border-gray-100 bg-gray-50/70 px-5 py-4"
data-testid="evidence-add-form"
id={addFormId}
onSubmit={(event) => {
event.preventDefault();
if (!draft.fileType) {
@ -336,101 +474,7 @@ export function RequirementsTable({
}
add.mutate(draft);
}}
>
<p className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
<Plus className="h-3.5 w-3.5" />
Add a document
</p>
<div className="flex flex-wrap items-center gap-3">
<select
className={`${selectClass} min-w-[11rem] flex-[2]`}
value={draft.fileType}
disabled={busy}
aria-label="Document"
data-testid="evidence-add-file-type"
onChange={(event) =>
setDraft({ ...draft, fileType: event.target.value })
}
>
<option value="">Choose a document</option>
{FILE_TYPE_GROUPS.map((group) => (
<optgroup key={group.group} label={group.group}>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
<select
className={`${selectClass} min-w-[10rem] flex-1`}
value={draft.projectWorkstreamStageId}
disabled={busy || !hasStages}
aria-label="Stage"
data-testid="evidence-add-stage"
onChange={(event) =>
setDraft({
...draft,
projectWorkstreamStageId: event.target.value,
})
}
>
<option value={NO_STAGE}>
{hasStages ? "No stage — whole workstream" : "No stages yet"}
</option>
{workstream.stages.map((stage) => (
<option key={stage.id} value={stage.id}>
{stage.name}
</option>
))}
</select>
<input
type="text"
className={`${inputClass} min-w-[10rem] flex-1`}
value={draft.namingRule}
disabled={busy}
placeholder="Naming rule, e.g. [WORef]_Cert"
aria-label="Naming rule"
data-testid="evidence-add-naming-rule"
onChange={(event) =>
setDraft({ ...draft, namingRule: event.target.value })
}
/>
<label className="flex items-center gap-2 whitespace-nowrap text-xs text-gray-600">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
checked={draft.required}
disabled={busy}
data-testid="evidence-add-required"
onChange={(event) =>
setDraft({ ...draft, required: event.target.checked })
}
/>
Required
</label>
<Button
type="submit"
size="sm"
disabled={busy}
data-testid="evidence-add-submit"
>
Add
</Button>
</div>
<p className="mt-2 text-xs text-gray-400">
{hasStages
? "Leave the stage unset to expect the document at any point in the workstream."
: "This workstream has no stages yet, so a requirement applies to the workstream as a whole. Tag it with a stage once the ladder exists."}
</p>
</form>
/>
)}
<ConfirmDialog