Simplify habit creation with editable starters and compact colors
This commit is contained in:
78
src/components/HabitForm.test.tsx
Normal file
78
src/components/HabitForm.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, useState } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { HabitForm } from "./HabitForm";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
|
||||
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
|
||||
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
({ createRoot } = await import("react-dom/client"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
dom.happyDOM.abort();
|
||||
for (const [key, descriptor] of originalGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
|
||||
function button(text: string) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||
}
|
||||
|
||||
test("habit starters remain editable and custom creation resets the draft", async () => {
|
||||
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
|
||||
await act(async () => button("Reading").click());
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read");
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("count");
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
|
||||
await act(async () => button("Evening routine").click());
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("tasks");
|
||||
expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow");
|
||||
await act(async () => button("Start from scratch").click());
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("manual");
|
||||
});
|
||||
|
||||
test("compact palette discloses full controls and preserves custom color", async () => {
|
||||
function Picker() {
|
||||
const [color, setColor] = useState("#123456");
|
||||
return <HabitColorPicker value={color} onChange={setColor} compact />;
|
||||
}
|
||||
await act(async () => root.render(<Picker />));
|
||||
expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8);
|
||||
const disclosure = button("More colors");
|
||||
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
|
||||
expect(details.hidden).toBe(true);
|
||||
await act(async () => disclosure.click());
|
||||
expect(details.hidden).toBe(false);
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
|
||||
await act(async () => button("Fewer colors").click());
|
||||
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { habitInput, type Schedule } from "../habits/contracts";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
|
||||
const STARTERS = [
|
||||
{ label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#977344" },
|
||||
{ label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" },
|
||||
{ label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" },
|
||||
] as const;
|
||||
|
||||
/** Account behavior composed from the design system's existing form components. */
|
||||
export function HabitForm({
|
||||
date,
|
||||
@@ -23,14 +29,26 @@ export function HabitForm({
|
||||
const saving = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
|
||||
const [target, setTarget] = useState(8);
|
||||
const [unit, setUnit] = useState("glasses");
|
||||
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("#58765b");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function applyStarter(starter: (typeof STARTERS)[number] | null) {
|
||||
setName(starter?.name ?? "");
|
||||
setMethod(starter?.method ?? "manual");
|
||||
setTarget(starter?.target ?? 10);
|
||||
setUnit(starter?.unit ?? "pages");
|
||||
setTasks(starter ? [...starter.tasks] : [""]);
|
||||
setColor(starter?.color ?? "#58765b");
|
||||
setSchedule({ type: "daily" });
|
||||
setError("");
|
||||
form.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
@@ -78,6 +96,15 @@ export function HabitForm({
|
||||
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." 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" disabled={busy}>
|
||||
<div className="ds-habit-starters" role="group" aria-label="Start with an idea">
|
||||
<p className="ds-footnote">Start with an idea, then make it your own.</p>
|
||||
<div className="ds-habit-starter-options">
|
||||
{STARTERS.map((starter) => (
|
||||
<Button key={starter.label} variant="secondary" onClick={() => applyStarter(starter)}>{starter.label}</Button>
|
||||
))}
|
||||
<Button variant="text" onClick={() => applyStarter(null)}>Start from scratch</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Habit name">
|
||||
{(id) => (
|
||||
<input
|
||||
@@ -101,13 +128,13 @@ export function HabitForm({
|
||||
>
|
||||
<option value="manual">A simple check-in</option>
|
||||
<option value="count">A count target</option>
|
||||
<option value="tasks">A list of tasks</option>
|
||||
<option value="tasks">A routine checklist</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{method === "count" && (
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">
|
||||
<Field label="Target per scheduled day">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
@@ -186,7 +213,7 @@ export function HabitForm({
|
||||
onChange={setSchedule}
|
||||
anchorDate={date}
|
||||
/>
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" compact />
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p className="ds-form-feedback" role="alert">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { HABIT_COLORS, progressShade } from "./calendar-model";
|
||||
import { Field } from "./Field";
|
||||
|
||||
@@ -41,19 +41,36 @@ export function HabitColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
mode = "demo",
|
||||
compact = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
mode?: "demo" | "create" | "edit";
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !compact || expanded;
|
||||
const valid = isHabitColor(value);
|
||||
return (
|
||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||
<legend>Habit color</legend>
|
||||
<p id={`${id}-hint`} className="ds-footnote">
|
||||
Choose a suggested shade or enter a custom color for this habit’s calendar.
|
||||
{showDetails ? "Choose a suggested shade or enter a custom color for this habit’s calendar." : "Choose a color for your habit."}
|
||||
</p>
|
||||
{compact && (
|
||||
<>
|
||||
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
|
||||
<span style={{ backgroundColor: palette.colors[0] }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
|
||||
</>
|
||||
)}
|
||||
<div id={`${id}-details`} hidden={!showDetails}>
|
||||
<div className="ds-color-palettes">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<div
|
||||
@@ -112,6 +129,7 @@ export function HabitColorPicker({
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{valid && (
|
||||
<div
|
||||
className="ds-color-live-preview"
|
||||
|
||||
Reference in New Issue
Block a user