Spaces:
Running
Running
| import { format, parseISO } from "date-fns"; | |
| import { AlertTriangle, ArrowLeft, ArrowRight, DollarSign, Flag, Smile } from "lucide-react"; | |
| import { useEffect, useRef, useState } from "react"; | |
| import { createPortal } from "react-dom"; | |
| const MILESTONE_ICONS = [ | |
| { id: "budget", label: "Budget", icon: DollarSign }, | |
| { id: "alert", label: "Alert", icon: AlertTriangle }, | |
| { id: "start", label: "Start", icon: ArrowRight }, | |
| { id: "end", label: "End", icon: ArrowLeft }, | |
| { id: "flag", label: "Flag", icon: Flag }, | |
| { id: "note", label: "Note", icon: Smile }, | |
| ] as const; | |
| interface MilestoneAddPopoverProps { | |
| anchor: { top: number; left: number }; | |
| dueDate: string; | |
| onCancel: () => void; | |
| onCreate: (name: string, note?: string) => void; | |
| isSaving?: boolean; | |
| } | |
| export function MilestoneAddPopover({ | |
| anchor, | |
| dueDate, | |
| onCancel, | |
| onCreate, | |
| isSaving = false, | |
| }: MilestoneAddPopoverProps) { | |
| const popoverRef = useRef<HTMLDivElement>(null); | |
| const [name, setName] = useState(""); | |
| const [note, setNote] = useState(""); | |
| const [iconId, setIconId] = useState<(typeof MILESTONE_ICONS)[number]["id"]>("flag"); | |
| useEffect(() => { | |
| const onKey = (event: KeyboardEvent) => { | |
| if (event.key === "Escape") onCancel(); | |
| }; | |
| const onDown = (event: MouseEvent) => { | |
| if (popoverRef.current?.contains(event.target as Node)) return; | |
| onCancel(); | |
| }; | |
| window.addEventListener("keydown", onKey); | |
| document.addEventListener("mousedown", onDown); | |
| return () => { | |
| window.removeEventListener("keydown", onKey); | |
| document.removeEventListener("mousedown", onDown); | |
| }; | |
| }, [onCancel]); | |
| const style = { | |
| top: Math.min(anchor.top, window.innerHeight - 360), | |
| left: Math.min(Math.max(8, anchor.left), window.innerWidth - 320), | |
| }; | |
| const dateLabel = format(parseISO(dueDate), "d MMM yyyy"); | |
| return createPortal( | |
| <div className="milestone-add-popover" ref={popoverRef} style={style}> | |
| <header className="milestone-add-popover-header"> | |
| <strong>Add Milestone</strong> | |
| <span className="muted-text">{dateLabel}</span> | |
| <button aria-label="Close" className="icon-button" onClick={onCancel} type="button"> | |
| ✕ | |
| </button> | |
| </header> | |
| <div className="milestone-icon-picker"> | |
| {MILESTONE_ICONS.map((item) => { | |
| const Icon = item.icon; | |
| return ( | |
| <button | |
| aria-label={item.label} | |
| className={`milestone-icon-option${iconId === item.id ? " selected" : ""}`} | |
| key={item.id} | |
| onClick={() => setIconId(item.id)} | |
| type="button" | |
| > | |
| <Icon size={16} /> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <div className="milestone-add-popover-body"> | |
| <label> | |
| Name | |
| <input | |
| autoFocus | |
| onChange={(event) => setName(event.target.value)} | |
| placeholder="Milestone name" | |
| type="text" | |
| value={name} | |
| /> | |
| </label> | |
| <label> | |
| Note <small>(optional)</small> | |
| <textarea | |
| onChange={(event) => setNote(event.target.value)} | |
| placeholder="Add a note" | |
| rows={3} | |
| value={note} | |
| /> | |
| </label> | |
| </div> | |
| <footer className="milestone-add-popover-footer"> | |
| <button | |
| className="primary-button" | |
| disabled={isSaving || !name.trim()} | |
| onClick={() => onCreate(name.trim(), note.trim() || undefined)} | |
| type="button" | |
| > | |
| Create | |
| </button> | |
| </footer> | |
| </div>, | |
| document.body, | |
| ); | |
| } | |