// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm). const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React; function Dialog({ open, onClose, children, width = 460, labelledBy }) { useEffectD(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") onClose?.(); }; document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose?.(); }}>
{children}
); } function DialogHeader({ eyebrow, title, onClose, id }) { return (
{eyebrow &&
{eyebrow}
}

{title}

{onClose && ( )}
); } // --- Item editor (used for both edit and add) --- function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) { const [title, setTitle] = useStateD(""); const [priority, setPriority] = useStateD("P2"); const [status, setStatus] = useStateD("open"); const [due, setDue] = useStateD(""); const [tags, setTags] = useStateD([]); const [tagInput, setTagInput] = useStateD(""); const [reason, setReason] = useStateD(""); const [progress, setProgress] = useStateD(0); const titleRef = useRefD(null); useEffectD(() => { if (!open) return; setTitle(initial?.title || ""); setPriority(initial?.priority || "P2"); setStatus(initial?.status || "open"); setDue(initial?.due || ""); setTags(initial?.tags || []); setTagInput(""); setReason(initial?.reason || ""); setProgress(initial?.progress ?? 0); setTimeout(() => titleRef.current?.focus(), 50); }, [open, initial]); const addTag = (t) => { const v = t.trim().replace(/^#/, ""); if (!v) return; if (tags.includes(v)) return; setTags([...tags, v]); }; const removeTag = (t) => setTags(tags.filter(x => x !== t)); const submit = (e) => { e?.preventDefault(); if (!title.trim()) return; // Reconcile progress with status before submitting. let outProgress = snapProgress(progress); if (status === "done") outProgress = 100; onSubmit({ title: title.trim(), priority, status, due: due || null, tags, reason: status === "blocked" ? reason.trim() || null : null, progress: outProgress }); }; const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8); return (
setTitle(e.target.value)} placeholder="What needs to happen?" required />
{PRIORITIES.map(p => ( ))}
setDue(e.target.value)}/>
{STATUSES.map(s => ( ))}
{status !== "cancelled" && (
{PROGRESS_STEPS.map(p => ( ))}
)} {status === "blocked" && (
setReason(e.target.value)} placeholder="Waiting for…"/>
)}
⌘↩ to save · Esc to cancel
); } // --- Confirm dialog --- function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) { return (

{message}

{detail &&
{detail}
}
); } // --- Tag autocomplete (typed input + filtered dropdown) --- function TagAutocompleteInput({ tags, setTags, tagInput, setTagInput, addTag, removeTag, allTags }) { const [focused, setFocused] = useStateD(false); const [highlighted, setHighlighted] = useStateD(0); const inputRef = useRefD(null); const wrapRef = useRefD(null); const q = tagInput.trim().toLowerCase().replace(/^#/, ""); // suggestions: filter out already-applied; rank exact prefix > contains const pool = (allTags || []).filter(t => !tags.includes(t)); let suggestions; if (q) { const starts = pool.filter(t => t.toLowerCase().startsWith(q)); const contains = pool.filter(t => !t.toLowerCase().startsWith(q) && t.toLowerCase().includes(q)); suggestions = [...starts, ...contains].slice(0, 8); } else { suggestions = pool.slice(0, 8); // recents } const exactMatch = q && pool.some(t => t.toLowerCase() === q); const showCreate = q && !exactMatch && !tags.includes(q); // index 0 = "create new" (if shown), then suggestions const items = [ ...(showCreate ? [{ kind: "create", value: q }] : []), ...suggestions.map(t => ({ kind: "suggestion", value: t })) ]; useEffectD(() => { setHighlighted(0); }, [tagInput, focused]); const pick = (it) => { if (!it) return; addTag(it.value); setTagInput(""); setHighlighted(0); }; const onKey = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (items.length) setHighlighted((h) => (h + 1) % items.length); } else if (e.key === "ArrowUp") { e.preventDefault(); if (items.length) setHighlighted((h) => (h - 1 + items.length) % items.length); } else if (e.key === "Enter") { e.preventDefault(); if (items[highlighted]) pick(items[highlighted]); else if (tagInput.trim()) { addTag(tagInput); setTagInput(""); } } else if (e.key === "," || e.key === "Tab") { if (tagInput.trim()) { e.preventDefault(); if (items[highlighted]) pick(items[highlighted]); else { addTag(tagInput); setTagInput(""); } } } else if (e.key === "Backspace" && !tagInput && tags.length) { removeTag(tags[tags.length - 1]); } else if (e.key === "Escape") { if (tagInput) { setTagInput(""); e.preventDefault(); } else inputRef.current?.blur(); } }; return (
{ if (e.target === e.currentTarget) inputRef.current?.focus(); }}> {tags.map(t => ( #{t} ))} setTagInput(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setTimeout(() => setFocused(false), 150)} onKeyDown={onKey} autoComplete="off" aria-autocomplete="list" aria-expanded={focused && items.length > 0} />
{focused && items.length > 0 && (
{!q && (
Recent
)} {items.map((it, i) => ( ))}
↑↓ to navigate ↵ to add esc to dismiss
)}
); } function TagMatchHL({ text, match }) { const i = text.toLowerCase().indexOf(match); if (i < 0) return `#${text}`; return ( <> #{text.slice(0, i)}{text.slice(i, i + match.length)}{text.slice(i + match.length)} ); } window.Dialog = Dialog; window.ItemDialog = ItemDialog; window.ConfirmDialog = ConfirmDialog;