Improve quick-add UX and fix visual clipping in board cards

- Let the quick-add dialog target any group via a new group dropdown,
  instead of always defaulting to the first group
- Only clip category lane content while it's actively collapsing, so
  it no longer crops a card's hover lift/top stroke at rest
- Raise the progress pie badge's z-index so it isn't cropped by the
  next card overlapping its corner
This commit is contained in:
Brian Fertig 2026-08-25 14:24:34 -06:00
parent a02b4a9c9c
commit cb52dfe545
4 changed files with 72 additions and 4 deletions

View File

@ -177,7 +177,11 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
className="grid transition-[grid-template-rows] duration-200 ease-in"
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
{/* overflow-hidden only while actually shrinking (grid-template-rows
easing to 0fr above) -- at rest it clips nothing, but clipped
unconditionally it also cropped the card's own hover lift
(hover:-translate-y-0.5 in GroupCard), cutting off its top stroke. */}
<div className={cn(holdPhase === "collapsing" ? "overflow-hidden" : "overflow-visible")}>
<div
className="transition-opacity duration-1000 ease-in"
style={{

View File

@ -167,7 +167,7 @@ function Board({ title }: { title: string }) {
}
/>
<TooltipContent side="bottom">
{quickTargetGroup ? "Quickly add a to-do to the first group" : "Add a group first, then quickly add to-dos"}
{quickTargetGroup ? "Quickly add a to-do to any group" : "Add a group first, then quickly add to-dos"}
</TooltipContent>
</Tooltip>
<ViewSwitcher />
@ -197,6 +197,7 @@ function Board({ title }: { title: string }) {
<TodoCreateDialog
groupId={quickTargetGroup.id}
categoryId={quickTargetCategory.id}
categories={categories}
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
/>

View File

@ -6,9 +6,17 @@ import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MDEditor } from "@/components/markdown/markdown-widgets";
import { useBoard } from "@/components/board/board-context";
import type { CategoryDTO } from "@/types/board";
const TITLE_MAX = 20;
@ -19,11 +27,21 @@ const TITLE_MAX = 20;
export function TodoCreateDialog({
groupId,
categoryId,
categories,
open,
onOpenChange,
}: {
// Default/only target group+category. When `categories` is also passed,
// this is just the pre-selected option in the "Group" dropdown below,
// not the final destination.
groupId: string;
categoryId: string;
// Pass the full board (or leave undefined) to show a "Group" dropdown
// letting the user redirect the to-do elsewhere -- used by the quick-add
// button, whose default target is otherwise just "whichever group
// happens to be first". Omitted from the per-group "+" menu, where the
// group is already unambiguous from context.
categories?: CategoryDTO[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@ -32,12 +50,23 @@ export function TodoCreateDialog({
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const [selectedGroupId, setSelectedGroupId] = useState(groupId);
const [pending, setPending] = useState(false);
const groupOptions = categories?.flatMap((c) =>
c.groups.map((g) => ({ groupId: g.id, categoryId: c.id, label: `${c.name} - ${g.title}` }))
);
// Only meaningful when `categories` is passed -- otherwise the dialog's
// single fixed target (the props above) is used as-is.
const targetCategoryId =
groupOptions?.find((o) => o.groupId === selectedGroupId)?.categoryId ?? categoryId;
const targetGroupId = groupOptions ? selectedGroupId : groupId;
function handleOpenChange(next: boolean) {
if (!next) {
setTitle("");
setDetails("");
setSelectedGroupId(groupId);
}
onOpenChange(next);
}
@ -47,7 +76,7 @@ export function TodoCreateDialog({
const trimmed = title.trim();
if (!trimmed) return;
setPending(true);
const ok = await addTodo(groupId, categoryId, trimmed, details.trim() || undefined);
const ok = await addTodo(targetGroupId, targetCategoryId, trimmed, details.trim() || undefined);
setPending(false);
if (ok) handleOpenChange(false);
}
@ -59,6 +88,34 @@ export function TodoCreateDialog({
<DialogTitle>Add to-do</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{groupOptions && (
<div className="space-y-2">
<Label htmlFor={`create-todo-group-${groupId}`}>Group</Label>
<Select
value={selectedGroupId}
// Without `items`, <Select.Value> has no way to look up a
// label for the current value until the popup has actually
// been opened once (that's what registers each item's
// label) -- until then, and again after it closes, it
// falls back to displaying the raw value, i.e. the group's
// id. Passing the same label list here directly is what
// the trigger actually reads from.
items={groupOptions.map((o) => ({ value: o.groupId, label: o.label }))}
onValueChange={(v) => v && setSelectedGroupId(v)}
>
<SelectTrigger id={`create-todo-group-${groupId}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{groupOptions.map((o) => (
<SelectItem key={o.groupId} value={o.groupId}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor={`create-todo-title-${groupId}`}>Title</Label>
<Input

View File

@ -34,7 +34,13 @@ export function TodoProgressPie({
role="img"
aria-label={`${pct}% of to-dos completed`}
tabIndex={0}
className="absolute -right-1.5 -bottom-1.5 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
// z-10: this badge pokes outside its own card's bottom-right
// corner, into the gap where the *next* card down starts. That
// next card is a later DOM sibling with its own stacking
// context (see hover:-translate-y-0.5 in GroupCard), so
// without an explicit z-index here it paints on top and crops
// the badge's overlapping edge.
className="absolute -right-1.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{
borderColor: accentColor,
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,