Everything under it is in order most of the time, and a card that is four paragraphs of "fine" pushes the settings that are not fine off the screen. So it shuts, with a check on the right, and opens on a click. "Finished" is three bits reported upward by the parts that own them: the path is saved, the checkout is a repository, and media/ both exists and is current. None of those is knowable in the parent — each child fetches its own status — and lifting all of it up to answer one question would be a far worse trade than three booleans. It is only collapsible while settled. A block you can shut on unfinished setup is a way to hide the thing you came here to fix. And the children are NOT RENDERED while it is shut rather than hidden, because a collapsed block whose children still poll is a settled panel making requests all afternoon about something nobody is looking at. The `null` in `collapsed` is a sentinel for "nobody has decided", which is what lets a deliberate expand survive. Setting it to false while unsettled spent it on the first render — before any status had arrived — so the block could never fold itself away at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
import type { LucideIcon } from 'lucide-react';
|
|
import { ChevronDown } from 'lucide-react';
|
|
|
|
/**
|
|
* One titled block of Administration.
|
|
*
|
|
* Every setting gets one, so the second and third cost nothing to add — as against a single form that
|
|
* has to be broken up the first time anything joins it.
|
|
*
|
|
* ── `status` is top-right, and it is for things that come and go ──
|
|
*
|
|
* Beside the title rather than under the controls: it is a remark about the block, not part of what
|
|
* you are doing, and put in the flow it pushes everything below it down by a line and then pulls it
|
|
* back up — a layout that jumps every time somebody saves.
|
|
*
|
|
* Each block renders its OWN Block, rather than the panel wrapping them. The status belongs to the
|
|
* setting that produced it, and lifting that state up to the panel would put one block's timer in a
|
|
* component that has nothing to do with it.
|
|
*
|
|
* ── Collapsing is CONTROLLED, and that is the whole design ──
|
|
*
|
|
* This component does not decide when a block is finished — it cannot, because "finished" is different
|
|
* for every setting and only the block itself knows. So the owner passes `collapsed` and gets told
|
|
* about a click, and what remains here is the chrome: a header that is a button, and children that are
|
|
* not rendered while it is shut.
|
|
*
|
|
* NOT RENDERED rather than hidden. A collapsed block's children keep polling if they are merely
|
|
* display:none, which is how a settled panel goes on making requests all afternoon about something
|
|
* nobody is looking at.
|
|
*/
|
|
export const Block = ({
|
|
icon: Icon,
|
|
title,
|
|
hint,
|
|
status,
|
|
collapsible = false,
|
|
collapsed = false,
|
|
onCollapsedChange,
|
|
children,
|
|
}: {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
hint: string;
|
|
/** Top-right. Usually ephemeral — see `useFlash`. */
|
|
status?: React.ReactNode;
|
|
/** The header becomes a button. The owner decides when this is worth offering. */
|
|
collapsible?: boolean;
|
|
collapsed?: boolean;
|
|
onCollapsedChange?: (collapsed: boolean) => void;
|
|
children?: React.ReactNode;
|
|
}) => {
|
|
const shut = collapsible && collapsed;
|
|
|
|
const header = (
|
|
<div className="flex items-start justify-between gap-3 text-left">
|
|
<div className="min-w-0 space-y-1">
|
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
|
<Icon className="size-4" />
|
|
{title}
|
|
{collapsible && (
|
|
<ChevronDown
|
|
className={`size-3.5 text-muted-foreground transition-transform ${shut ? '-rotate-90' : ''}`}
|
|
/>
|
|
)}
|
|
</h3>
|
|
{/* The hint is what the block is FOR, so it stays whether or not the block is open — a shut
|
|
row with only a title says nothing to somebody who has not opened it before. */}
|
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
|
</div>
|
|
{/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow
|
|
to guard against — and an always-present placeholder would read as a field. */}
|
|
{status && <div className="shrink-0">{status}</div>}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
{collapsible ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onCollapsedChange?.(!collapsed)}
|
|
aria-expanded={!shut}
|
|
className="-m-1 cursor-pointer rounded p-1 hover:bg-muted/40"
|
|
>
|
|
{header}
|
|
</button>
|
|
) : (
|
|
header
|
|
)}
|
|
{!shut && children}
|
|
</section>
|
|
);
|
|
};
|