373 lines
13 KiB
React
373 lines
13 KiB
React
// 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 (
|
||
<div className="dlg-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
|
||
<div className="dlg" role="dialog" aria-modal="true" aria-labelledby={labelledBy} style={{ maxWidth: width }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DialogHeader({ eyebrow, title, onClose, id }) {
|
||
return (
|
||
<div className="dlg-head">
|
||
<div>
|
||
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
|
||
<h3 id={id} className="dlg-title">{title}</h3>
|
||
</div>
|
||
{onClose && (
|
||
<button className="dlg-close" onClick={onClose} aria-label="Close">
|
||
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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 (
|
||
<Dialog open={open} onClose={onClose} width={520} labelledBy="dlg-item-title">
|
||
<form onSubmit={submit}>
|
||
<DialogHeader
|
||
id="dlg-item-title"
|
||
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
|
||
title={mode === "edit" ? "Edit details" : "Create item"}
|
||
onClose={onClose}
|
||
/>
|
||
|
||
<div className="dlg-body">
|
||
<div className="field">
|
||
<label className="field-label">Title</label>
|
||
<input
|
||
ref={titleRef}
|
||
className="text-input"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="What needs to happen?"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="field-row">
|
||
<div className="field">
|
||
<label className="field-label">Priority</label>
|
||
<div className="seg">
|
||
{PRIORITIES.map(p => (
|
||
<button type="button" key={p}
|
||
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
|
||
onClick={() => setPriority(p)}>
|
||
{p === "P0" && <span className="p0-dot"/>}
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Due</label>
|
||
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="field-label">Status</label>
|
||
<div className="status-grid">
|
||
{STATUSES.map(s => (
|
||
<button type="button" key={s.key}
|
||
className={`status-card ${status === s.key ? "active" : ""}`}
|
||
onClick={() => setStatus(s.key)}>
|
||
<span className={`status-card-icon status-${s.key}`}>
|
||
<StatusIcon status={s.key} size={16}/>
|
||
</span>
|
||
<span className="status-card-label">{s.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{status !== "cancelled" && (
|
||
<div className="field">
|
||
<label className="field-label">
|
||
Completion
|
||
<span className="field-hint">
|
||
{status === "done"
|
||
? "locked at 100% while marked done"
|
||
: "rough estimate — set 100% to mark done"}
|
||
</span>
|
||
</label>
|
||
<div className="progress-row">
|
||
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}>
|
||
{PROGRESS_STEPS.map(p => (
|
||
<button type="button" key={p}
|
||
className={`seg-btn ${progress === p ? "active" : ""}`}
|
||
onClick={() => setProgress(p)}
|
||
disabled={status === "done"}>
|
||
{p}%
|
||
</button>
|
||
))}
|
||
</div>
|
||
<ProgressGauge value={status === "done" ? 100 : progress} size="md"/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status === "blocked" && (
|
||
<div className="field">
|
||
<label className="field-label">Block reason</label>
|
||
<input className="text-input" value={reason}
|
||
onChange={(e) => setReason(e.target.value)}
|
||
placeholder="Waiting for…"/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
|
||
<TagAutocompleteInput
|
||
tags={tags}
|
||
setTags={setTags}
|
||
tagInput={tagInput}
|
||
setTagInput={setTagInput}
|
||
addTag={addTag}
|
||
removeTag={removeTag}
|
||
allTags={recentTags}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dlg-foot">
|
||
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn-primary" disabled={!title.trim()}>
|
||
{mode === "edit" ? "Save changes" : "Create item"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// --- Confirm dialog ---
|
||
function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
|
||
return (
|
||
<Dialog open={open} onClose={onCancel} width={420} labelledBy="dlg-confirm-title">
|
||
<DialogHeader id="dlg-confirm-title" eyebrow="Confirm" title={title} onClose={onCancel}/>
|
||
<div className="dlg-body">
|
||
<p className="dlg-msg">{message}</p>
|
||
{detail && <div className="dlg-detail">{detail}</div>}
|
||
</div>
|
||
<div className="dlg-foot">
|
||
<span/>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
|
||
<button type="button"
|
||
className={danger ? "btn-danger" : "btn-primary"}
|
||
onClick={onConfirm} autoFocus>{confirmLabel}</button>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// --- 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 (
|
||
<div className="tag-ac" ref={wrapRef}>
|
||
<div className={`tag-input ${focused ? "focused" : ""}`} onClick={(e) => {
|
||
if (e.target === e.currentTarget) inputRef.current?.focus();
|
||
}}>
|
||
{tags.map(t => (
|
||
<span key={t} className="tag-pill">
|
||
#{t}
|
||
<button type="button" onClick={() => removeTag(t)} aria-label={`Remove ${t}`}>×</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
ref={inputRef}
|
||
className="tag-input-field"
|
||
placeholder={tags.length ? "+ add tag" : "e.g. urgent, frontend, errand"}
|
||
value={tagInput}
|
||
onChange={(e) => 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}
|
||
/>
|
||
</div>
|
||
|
||
{focused && items.length > 0 && (
|
||
<div className="tag-ac-pop" role="listbox">
|
||
{!q && (
|
||
<div className="tag-ac-cap">Recent</div>
|
||
)}
|
||
{items.map((it, i) => (
|
||
<button
|
||
key={`${it.kind}-${it.value}`}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={i === highlighted}
|
||
className={`tag-ac-item ${i === highlighted ? "highlighted" : ""} ${it.kind === "create" ? "create" : ""}`}
|
||
onMouseEnter={() => setHighlighted(i)}
|
||
onMouseDown={(e) => { e.preventDefault(); pick(it); }}
|
||
>
|
||
{it.kind === "create" ? (
|
||
<>
|
||
<Icon name="plus" size={11}/>
|
||
<span>Create <strong>#{it.value}</strong></span>
|
||
</>
|
||
) : (
|
||
<span>{q ? <TagMatchHL text={it.value} match={q}/> : `#${it.value}`}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
<div className="tag-ac-foot">
|
||
<span><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>
|
||
<span><kbd>↵</kbd> to add</span>
|
||
<span><kbd>esc</kbd> to dismiss</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TagMatchHL({ text, match }) {
|
||
const i = text.toLowerCase().indexOf(match);
|
||
if (i < 0) return `#${text}`;
|
||
return (
|
||
<>
|
||
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
window.Dialog = Dialog;
|
||
window.ItemDialog = ItemDialog;
|
||
window.ConfirmDialog = ConfirmDialog;
|