- Replaced HABIT_PALETTES with HABIT_COLOR_CHOICES in HabitColorPicker component and related tests. - Adjusted the layout and styles of the color picker for improved usability. - Updated tests to reflect changes in color selection logic. - Modified WorkspacePanel and SettingRow components to allow optional eyebrow and description props. - Enhanced accessibility tests for calendar navigation. - Updated calendar model to include a 1-month view option. - Refined styles across various components for consistency and improved responsiveness.
201 lines
6.7 KiB
TypeScript
201 lines
6.7 KiB
TypeScript
import { Modal } from "./design-system/Modal";
|
|
import { useRef, useState, type FormEvent } from "react";
|
|
import { Button } from "./design-system/primitives";
|
|
import { Field } from "./design-system/Field";
|
|
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
|
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
|
import { habitInput, type Schedule } from "../habits/contracts";
|
|
import { habitRequest } from "../lib/dashboard";
|
|
|
|
/** Account behavior composed from the design system's existing form components. */
|
|
export function HabitForm({
|
|
date,
|
|
onCancel,
|
|
onCreated,
|
|
onExpired,
|
|
}: {
|
|
date: string;
|
|
onCancel: () => void;
|
|
onCreated: (name: string) => void;
|
|
onExpired: () => void;
|
|
}) {
|
|
const form = useRef<HTMLFormElement>(null);
|
|
const saving = useRef(false);
|
|
const [name, setName] = useState("");
|
|
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
|
|
const [target, setTarget] = useState(10);
|
|
const [unit, setUnit] = useState("pages");
|
|
const [tasks, setTasks] = useState([""]);
|
|
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
|
const [color, setColor] = useState("#3e805d");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (saving.current) return;
|
|
const parsed = habitInput.safeParse({
|
|
name,
|
|
method,
|
|
schedule,
|
|
color,
|
|
...(method === "count" ? { target, unit } : {}),
|
|
...(method === "tasks" ? { tasks: tasks.map((name) => ({ name })) } : {}),
|
|
});
|
|
if (!parsed.success) {
|
|
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
|
|
return;
|
|
}
|
|
saving.current = true;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await habitRequest("/habits", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(parsed.data),
|
|
});
|
|
onCreated(parsed.data.name);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.includes("session has expired")
|
|
)
|
|
onExpired();
|
|
else
|
|
setError(
|
|
error instanceof Error
|
|
? error.message
|
|
: "Could not create your habit.",
|
|
);
|
|
} finally {
|
|
saving.current = false;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal id="new-habit" title="Create habit" onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
|
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
|
<fieldset className="ds-task-editor-fieldset ds-habit-form-grid" disabled={busy}>
|
|
<Field label="Habit name">
|
|
{(id) => (
|
|
<input
|
|
id={id}
|
|
required
|
|
maxLength={200}
|
|
placeholder="e.g. Read ten pages"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Field label="Tracking">
|
|
{(id) => (
|
|
<select
|
|
id={id}
|
|
value={method}
|
|
onChange={(event) =>
|
|
setMethod(event.target.value as typeof method)
|
|
}
|
|
>
|
|
<option value="manual">A simple check-in</option>
|
|
<option value="count">A count target</option>
|
|
<option value="tasks">A routine checklist</option>
|
|
</select>
|
|
)}
|
|
</Field>
|
|
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
|
{method === "count" && (
|
|
<div className="ds-form-grid">
|
|
<Field label="Daily target">
|
|
{(id) => (
|
|
<input
|
|
id={id}
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={10000}
|
|
step={1}
|
|
value={Number.isNaN(target) ? "" : target}
|
|
onChange={(event) => setTarget(event.target.valueAsNumber)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Field label="Unit">
|
|
{(id) => (
|
|
<input
|
|
id={id}
|
|
required
|
|
maxLength={80}
|
|
value={unit}
|
|
onChange={(event) => setUnit(event.target.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
)}
|
|
{method === "tasks" && (
|
|
<div className="ds-form-section">
|
|
{tasks.map((task, index) => (
|
|
<Field key={index} label={`Task ${index + 1}`}>
|
|
{(id) => (
|
|
<div className="ds-input-row">
|
|
<input
|
|
id={id}
|
|
required
|
|
maxLength={200}
|
|
value={task}
|
|
onChange={(event) =>
|
|
setTasks((current) =>
|
|
current.map((value, i) =>
|
|
i === index ? event.target.value : value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
<Button
|
|
variant="text"
|
|
aria-label={`Remove task ${index + 1}`}
|
|
disabled={tasks.length === 1}
|
|
onClick={() =>
|
|
setTasks((current) =>
|
|
current.filter((_, i) => i !== index),
|
|
)
|
|
}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Field>
|
|
))}
|
|
<Button
|
|
variant="secondary"
|
|
disabled={tasks.length >= 100}
|
|
onClick={() => setTasks((current) => [...current, ""])}
|
|
>
|
|
Add task +
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
|
</fieldset>
|
|
{error && (
|
|
<p className="ds-form-feedback" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="ds-form-actions">
|
|
<Button type="submit" disabled={busy}>
|
|
{busy ? "Creating…" : "Create habit"}
|
|
</Button>
|
|
<Button variant="text" disabled={busy} onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|