feat(ara-projects): unroll the permission detail instead of popping it (#413)

The sub-row's wrapper is a one-row grid whose track animates 0fr → 1fr over
160ms, so the detail unrolls to whatever height its own content needs — no
measuring, no ref, and no effect to start it: mounting runs the animation.
Rows below slide down with it rather than jumping. motion-reduce:animate-none
respects a reduced-motion preference.

Adds the `disclosure-open` keyframe to tailwind.config.js — namespaced and
additive, affecting nothing else. tailwindcss-animate is installed but not
registered in `plugins`, so the animate-in/slide-in-from-* utilities the
shadcn components reference generate no CSS; enabling that plugin would
change animation behaviour across every dialog and tooltip in the app, which
is not this ticket's call to make.

Collapsing is still immediate — React unmounts the row, so there is no exit
animation without holding it mounted through one.
This commit is contained in:
Daniel Roth 2026-07-23 15:55:49 +00:00
parent 2fe4b5ea17
commit feee863010
2 changed files with 87 additions and 54 deletions

View file

@ -142,7 +142,8 @@ export function ContractorPermissionsTable({
? { kind: "blocked", reason }
: { kind: "needs-confirmation", reason };
}
if (!res.ok) throw new Error(body.error ?? "Couldn't remove the contractor");
if (!res.ok)
throw new Error(body.error ?? "Couldn't remove the contractor");
return { kind: "removed" };
},
onSuccess: (outcome, { contractor }) => {
@ -321,7 +322,9 @@ export function ContractorPermissionsTable({
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-indigo-50 text-sm font-bold text-indigo-700">
{contractor.organisationName.charAt(0).toUpperCase()}
{contractor.organisationName
.charAt(0)
.toUpperCase()}
</span>
<span className="text-sm font-semibold text-gray-900">
{contractor.organisationName}
@ -391,59 +394,71 @@ export function ContractorPermissionsTable({
data-testid={`contractor-permissions-${contractor.organisationId}`}
>
{/* Full-bleed: the detail table *is* the expanded
space, spanning every column of the row above. */}
space, spanning every column of the row above.
The wrapper is a one-row grid whose track animates
0fr 1fr, so the detail unrolls to whatever height
its own content needs no measuring, and no effect
to run the animation: mounting starts it. */}
<td colSpan={4} className="p-0">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-gray-200 bg-gray-100/60 text-xs text-gray-600">
<th className="px-4 py-2 text-left font-semibold">
Workstream
</th>
{PERMISSION_COLUMNS.map((column) => (
<th
key={column.key}
className="w-40 px-4 py-2 text-left font-semibold"
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{contractor.assignments.map((assignment) => (
<tr
key={assignment.id}
className="border-b border-gray-100 last:border-b-0"
data-testid={`assignment-row-${assignment.id}`}
>
<td className="px-4 py-2.5">
<span className="text-sm font-medium text-gray-800">
{assignment.workstreamName}
</span>
{assignment.workOrders > 0 && (
<span className="ml-2 text-xs text-gray-400">
{assignment.workOrders}{" "}
{assignment.workOrders === 1
? "work order"
: "work orders"}
</span>
)}
</td>
{PERMISSION_COLUMNS.map((column) => (
<td key={column.key} className="px-4 py-2.5">
<PermissionState
granted={assignment[column.key]}
label={column.header}
workstreamName={
assignment.workstreamName
}
/>
</td>
<div className="grid animate-disclosure-open motion-reduce:animate-none">
<div className="min-h-0 overflow-hidden">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-gray-200 bg-gray-100/60 text-xs text-gray-600">
<th className="px-4 py-2 text-left font-semibold">
Workstream
</th>
{PERMISSION_COLUMNS.map((column) => (
<th
key={column.key}
className="w-40 px-4 py-2 text-left font-semibold"
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{contractor.assignments.map((assignment) => (
<tr
key={assignment.id}
className="border-b border-gray-100 last:border-b-0"
data-testid={`assignment-row-${assignment.id}`}
>
<td className="px-4 py-2.5">
<span className="text-sm font-medium text-gray-800">
{assignment.workstreamName}
</span>
{assignment.workOrders > 0 && (
<span className="ml-2 text-xs text-gray-400">
{assignment.workOrders}{" "}
{assignment.workOrders === 1
? "work order"
: "work orders"}
</span>
)}
</td>
{PERMISSION_COLUMNS.map((column) => (
<td
key={column.key}
className="px-4 py-2.5"
>
<PermissionState
granted={assignment[column.key]}
label={column.header}
workstreamName={
assignment.workstreamName
}
/>
</td>
))}
</tr>
))}
</tr>
))}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</td>
</tr>
)}
@ -467,7 +482,10 @@ export function ContractorPermissionsTable({
Work orders can only be imported once every workstream has both a
stage ladder and a contractor.
</p>
<ul className="mt-3 space-y-1.5" data-testid="contractors-readiness-list">
<ul
className="mt-3 space-y-1.5"
data-testid="contractors-readiness-list"
>
{readiness.incomplete.map((workstream) => (
<li
key={workstream.name}

View file

@ -190,12 +190,27 @@ module.exports = {
height: "0",
},
},
// A disclosure row unrolling to its own content height, without that
// height having to be measured: the wrapper is a one-row grid and the
// track animates from 0fr to 1fr. Used by the contractor permissions
// sub-row (#413) — see `animation["disclosure-open"]`.
"disclosure-open": {
from: {
gridTemplateRows: "0fr",
opacity: "0",
},
to: {
gridTemplateRows: "1fr",
opacity: "1",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"disclosure-open": "disclosure-open 160ms ease-out",
},
maxWidth: {
"8xl": "90rem",