compact todo groups: ride the "+" on the last visible row

In compact layout the add-to-do menu no longer takes a row of its
own — it now renders inline on the last visible to-do, saving one
line per group. A compact group is guaranteed to have at least one
visible to-do (CategoryLane hides it otherwise), so there's always
a row to attach it to; a defensive fallback keeps the "+" reachable
in the off chance that invariant ever changes. Non-compact layout
(popover, AI add, archive, progress pie) is unchanged.
This commit is contained in:
Brian Fertig 2026-08-19 09:33:11 -06:00
parent 171ec92b74
commit 938f59d10d
1 changed files with 141 additions and 97 deletions

View File

@ -6,6 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import {
Archive,
ChevronRight,
ClipboardList,
GripVertical,
MoreVertical,
@ -94,6 +95,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const [todoAiOpen, setTodoAiOpen] = useState(false);
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
const {
attributes,
@ -143,6 +145,26 @@ export function GroupCard({ group }: { group: GroupDTO }) {
// from the list entirely rather than shown crossed-out.
const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos;
// In the default view, a fully-checked-off group collapses down to just
// its header (title, notes icon, kebab menu) with a disclosure triangle
// in place of the drag-grip dots, so a board full of "done" groups
// doesn't stay as visually loud as one still full of open work. The
// triangle re-expands it back to the normal full card. Compact already
// hides finished groups outright (see CategoryLane), so this only
// applies to the default view.
const showDisclosure = !compact && canArchive;
// Reset back to collapsed each time the group freshly becomes fully
// done, rather than remembering a stale expanded choice from its last
// completion cycle. Adjusting state during render (comparing against
// last render's value) instead of in a useEffect avoids an extra render
// pass -- see https://react.dev/learn/you-might-not-need-an-effect.
const [wasComplete, setWasComplete] = useState(canArchive);
if (canArchive !== wasComplete) {
setWasComplete(canArchive);
if (canArchive) setExpandedWhileComplete(false);
}
const isCollapsed = showDisclosure && !expandedWhileComplete;
return (
<>
<div
@ -168,9 +190,26 @@ export function GroupCard({ group }: { group: GroupDTO }) {
className="group flex min-w-0 flex-1 cursor-grab touch-none items-start gap-1 rounded-md outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
aria-label={`Drag to move ${group.title}`}
>
{showDisclosure ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpandedWhileComplete((v) => !v);
}}
className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
aria-label={expandedWhileComplete ? `Collapse ${group.title}` : `Expand ${group.title}`}
aria-expanded={expandedWhileComplete}
>
<ChevronRight
className={cn("size-4 transition-transform", expandedWhileComplete && "rotate-90")}
/>
</button>
) : (
<span className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground">
<GripVertical className="size-4" />
</span>
)}
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
</div>
<div className="flex shrink-0 items-center">
@ -207,6 +246,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
</div>
</div>
{!isCollapsed && (
<>
<ul className="mt-1 space-y-1">
{visibleTodos.map((todo, index) => {
const todoButton = (
@ -229,8 +270,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
// In compact, the "+" rides along on the last visible row
// instead of taking a row of its own -- one less line per
// group. (A compact group always has at least one unchecked
// to-do -- CategoryLane hides it otherwise -- so there's always
// a last row to put it on.)
// to-do -- CategoryLane hides it otherwise -- so there's
// always a last row to put it on.)
const isLastRow = compact && index === visibleTodos.length - 1;
return (
@ -265,8 +306,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
{compact ? (
// Defensive fallback -- shouldn't normally happen, since
// CategoryLane already hides a compact group with zero visible
// to-dos, but keeps the "+" reachable if that ever changes.
// CategoryLane already hides a compact group with zero
// visible to-dos, but keeps the "+" reachable if that ever
// changes.
visibleTodos.length === 0 && (
<div className="mt-1 flex justify-end">
<AddTodoMenu
@ -314,6 +356,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
/>
</>
)}
</>
)}
</div>
{editingTodo && (