feat: implement ReasonPrompt component with quick tags
- Create ReasonPrompt dialog for capturing optional reasons - Add quick tag buttons (Blocking, Urgent, Context switch, etc.) - Support keyboard navigation (Escape to cancel) - Handle text input with trimming and null for empty - Different titles for different actions (set_focus, defer, skip) - All 10 tests pass Closes bd-2p0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
168
src/components/ReasonPrompt.tsx
Normal file
168
src/components/ReasonPrompt.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* ReasonPrompt -- capture optional reason for user actions.
|
||||
*
|
||||
* Every significant action prompts for an optional reason to learn patterns.
|
||||
* Quick tags allow fast categorization without typing.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
const QUICK_TAGS = [
|
||||
{ id: "blocking", label: "Blocking" },
|
||||
{ id: "urgent", label: "Urgent" },
|
||||
{ id: "context_switch", label: "Context switch" },
|
||||
{ id: "energy", label: "Energy" },
|
||||
{ id: "flow", label: "Flow" },
|
||||
];
|
||||
|
||||
const ACTION_TITLES: Record<string, string> = {
|
||||
set_focus: "Setting focus to",
|
||||
defer: "Deferring",
|
||||
skip: "Skipping",
|
||||
archive: "Archiving",
|
||||
complete: "Completing",
|
||||
};
|
||||
|
||||
interface ReasonPromptProps {
|
||||
action: string;
|
||||
itemTitle: string;
|
||||
onSubmit: (data: { reason: string | null; tags: string[] }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ReasonPrompt({
|
||||
action,
|
||||
itemTitle,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ReasonPromptProps): React.ReactElement {
|
||||
const [reason, setReason] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedReason = reason.trim();
|
||||
onSubmit({
|
||||
reason: trimmedReason || null,
|
||||
tags: selectedTags,
|
||||
});
|
||||
}, [reason, selectedTags, onSubmit]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onSubmit({ reason: null, tags: [] });
|
||||
}, [onSubmit]);
|
||||
|
||||
const toggleTag = useCallback((tagId: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((t) => t !== tagId) : [...prev, tagId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const actionTitle = ACTION_TITLES[action] || action;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reason-prompt-title"
|
||||
className="w-full max-w-md rounded-lg border border-zinc-700 bg-surface-base p-6 shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<h2
|
||||
id="reason-prompt-title"
|
||||
className="mb-4 text-lg font-semibold text-zinc-200"
|
||||
>
|
||||
{actionTitle}: {itemTitle}
|
||||
</h2>
|
||||
|
||||
{/* Reason input */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="reason-input"
|
||||
className="mb-2 block text-sm text-zinc-400"
|
||||
>
|
||||
Why? (optional, helps learn your patterns)
|
||||
</label>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
id="reason-input"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g., Sarah pinged me, she's blocked on release"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-zinc-700 bg-surface-raised px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick tags */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-zinc-400">Quick tags:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{QUICK_TAGS.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? "bg-zinc-600 text-zinc-100"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="rounded-md px-4 py-2 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
Skip reason
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-500"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user