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:
parent
171ec92b74
commit
938f59d10d
|
|
@ -6,6 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
ChevronRight,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
|
@ -94,6 +95,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
|
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -143,6 +145,26 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
// from the list entirely rather than shown crossed-out.
|
// from the list entirely rather than shown crossed-out.
|
||||||
const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<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"
|
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}`}
|
aria-label={`Drag to move ${group.title}`}
|
||||||
>
|
>
|
||||||
<span className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground">
|
{showDisclosure ? (
|
||||||
<GripVertical className="size-4" />
|
<button
|
||||||
</span>
|
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>
|
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
|
|
@ -207,111 +246,116 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="mt-1 space-y-1">
|
{!isCollapsed && (
|
||||||
{visibleTodos.map((todo, index) => {
|
<>
|
||||||
const todoButton = (
|
<ul className="mt-1 space-y-1">
|
||||||
<button
|
{visibleTodos.map((todo, index) => {
|
||||||
type="button"
|
const todoButton = (
|
||||||
onClick={() => setEditingTodo(todo)}
|
<button
|
||||||
style={todo.completed ? undefined : { color: todoTextColor }}
|
type="button"
|
||||||
className={cn(
|
onClick={() => setEditingTodo(todo)}
|
||||||
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
style={todo.completed ? undefined : { color: todoTextColor }}
|
||||||
todo.completed && "text-muted-foreground line-through"
|
className={cn(
|
||||||
)}
|
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
||||||
>
|
todo.completed && "text-muted-foreground line-through"
|
||||||
<span className="min-w-0 truncate">{todo.title}</span>
|
)}
|
||||||
{todo.details && (
|
>
|
||||||
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
|
<span className="min-w-0 truncate">{todo.title}</span>
|
||||||
)}
|
{todo.details && (
|
||||||
</button>
|
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
);
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
// In compact, the "+" rides along on the last visible row
|
// In compact, the "+" rides along on the last visible row
|
||||||
// instead of taking a row of its own -- one less line per
|
// instead of taking a row of its own -- one less line per
|
||||||
// group. (A compact group always has at least one unchecked
|
// group. (A compact group always has at least one unchecked
|
||||||
// to-do -- CategoryLane hides it otherwise -- so there's always
|
// to-do -- CategoryLane hides it otherwise -- so there's
|
||||||
// a last row to put it on.)
|
// always a last row to put it on.)
|
||||||
const isLastRow = compact && index === visibleTodos.length - 1;
|
const isLastRow = compact && index === visibleTodos.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
||||||
<TodoCheckbox
|
<TodoCheckbox
|
||||||
checked={todo.completed}
|
checked={todo.completed}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
||||||
}
|
}
|
||||||
accentColor={borderColor}
|
accentColor={borderColor}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
||||||
/>
|
/>
|
||||||
{todo.details ? (
|
{todo.details ? (
|
||||||
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
||||||
) : (
|
) : (
|
||||||
todoButton
|
todoButton
|
||||||
)}
|
)}
|
||||||
{isLastRow && (
|
{isLastRow && (
|
||||||
|
<AddTodoMenu
|
||||||
|
group={group}
|
||||||
|
aiConfigured={aiConfigured}
|
||||||
|
onAddClick={() => setTodoCreateOpen(true)}
|
||||||
|
onAiClick={() => setTodoAiOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{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.
|
||||||
|
visibleTodos.length === 0 && (
|
||||||
|
<div className="mt-1 flex justify-end">
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
aiConfigured={aiConfigured}
|
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => setTodoCreateOpen(true)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => setTodoAiOpen(true)}
|
||||||
|
aiConfigured={aiConfigured}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-1">
|
||||||
|
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiConfigured && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2 text-muted-foreground"
|
||||||
|
onClick={() => setTodoAiOpen(true)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
Add using AI
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{compact ? (
|
{canArchive && (
|
||||||
// Defensive fallback -- shouldn't normally happen, since
|
<Button
|
||||||
// CategoryLane already hides a compact group with zero visible
|
variant="ghost"
|
||||||
// to-dos, but keeps the "+" reachable if that ever changes.
|
size="sm"
|
||||||
visibleTodos.length === 0 && (
|
className="w-full justify-start gap-2 text-muted-foreground"
|
||||||
<div className="mt-1 flex justify-end">
|
onClick={() => archiveGroup(group.id, group.categoryId)}
|
||||||
<AddTodoMenu
|
>
|
||||||
group={group}
|
<Archive className="size-3.5" />
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
Archive
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
</Button>
|
||||||
aiConfigured={aiConfigured}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-1">
|
|
||||||
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{aiConfigured && (
|
<TodoProgressPie
|
||||||
<Button
|
completed={completedCount}
|
||||||
variant="ghost"
|
total={group.todos.length}
|
||||||
size="sm"
|
accentColor={pieAccentColor}
|
||||||
className="w-full justify-start gap-2 text-muted-foreground"
|
/>
|
||||||
onClick={() => setTodoAiOpen(true)}
|
</>
|
||||||
>
|
|
||||||
<Sparkles className="size-3.5" />
|
|
||||||
Add using AI
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canArchive && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start gap-2 text-muted-foreground"
|
|
||||||
onClick={() => archiveGroup(group.id, group.categoryId)}
|
|
||||||
>
|
|
||||||
<Archive className="size-3.5" />
|
|
||||||
Archive
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TodoProgressPie
|
|
||||||
completed={completedCount}
|
|
||||||
total={group.todos.length}
|
|
||||||
accentColor={pieAccentColor}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue