From 608ca652ec6c993e05c559d88ec11a183bf6533f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 7 Jun 2026 07:04:25 +0200 Subject: [PATCH] feat: polish CRM task type picker --- web/src/components/app/crm/TaskTypePicker.tsx | 150 ++++++++++-------- 1 file changed, 87 insertions(+), 63 deletions(-) diff --git a/web/src/components/app/crm/TaskTypePicker.tsx b/web/src/components/app/crm/TaskTypePicker.tsx index 698b2b0a1..e29af3382 100644 --- a/web/src/components/app/crm/TaskTypePicker.tsx +++ b/web/src/components/app/crm/TaskTypePicker.tsx @@ -2,6 +2,14 @@ // create, rename, recolour, delete. The selected value is the type NAME // (empty string = no type). Shared by the tasks dialog and the campaign // "Create task" action editor so types stay consistent everywhere. +// +// The dropdown shell is the shared PopoverMenu primitive (portal + +// framer-motion animation + click-outside/Esc handled for us), so it +// stays smooth and consistent with the rest of the app chrome. The +// rich rows — select, inline rename/recolour, inline "New type" form — +// live as arbitrary children inside PopoverMenuContent. The edit/create +// forms stopPropagation on their interactions so typing or picking a +// swatch never bubbles up to close the menu. import React from "react"; import toast from "react-hot-toast"; @@ -14,7 +22,13 @@ import { Trash2Icon, XIcon, } from "lucide-react"; -import useClickOutside from "@/hooks/useClickOutside"; +import { + PopoverMenu, + PopoverMenuTrigger, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, +} from "@/components/ui/popover-menu"; import { useConfirm } from "@/hooks/context/confirm"; import useTaskTypes from "@/lib/api/hooks/app/crm/taskTypes/useTaskTypes"; import useCreateTaskType from "@/lib/api/hooks/app/crm/taskTypes/useCreateTaskType"; @@ -36,68 +50,61 @@ export default function TaskTypePicker({ }) { const { data: types = [], isPending } = useTaskTypes(); const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const selected = types.find((t) => t.name === value); return ( -
- + + + + - {open && ( -
- + + onChange("")} + selected={value === ""} + icon={} + trailing={ + value === "" ? : null + } + > + No type + -
- {isPending ? ( -
Loading…
- ) : ( - types.map((t) => ( - { - onChange(t.name); - setOpen(false); - }} - onDeletedSelected={() => onChange("")} - isSelectedValue={t.name === value} - /> - )) - )} -
- -
- onChange(name)} /> -
+
+ {isPending ? ( +
Loading…
+ ) : ( + types.map((t) => ( + { + onChange(t.name); + setOpen(false); + }} + onDeletedSelected={() => onChange("")} + isSelectedValue={t.name === value} + /> + )) + )}
- )} -
+ + + onChange(name)} /> + +
); } @@ -148,16 +155,25 @@ function TypeRow({ }); } + // The inline edit form is interactive — typing, swatch picks and the + // Save/Cancel buttons must never bubble up to the row's select handler + // or close the menu, so the wrapper stops propagation outright. if (editing) { return ( -
+
e.stopPropagation()} + > setName(e.target.value)} autoFocus onKeyDown={(e) => { if (e.key === "Enter") save(); - if (e.key === "Escape") setEditing(false); + if (e.key === "Escape") { + e.stopPropagation(); + setEditing(false); + } }} className="w-full h-7 px-2 rounded-md border border-slate-200 text-[12px] outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100" /> @@ -186,7 +202,7 @@ function TypeRow({ return (
void }) { return (