From 9f8154e412e142f7e66808765e31fd48f0e1f87d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 5 Sep 2026 07:51:22 +0200 Subject: [PATCH] Simplify habit creation with editable starters and compact colors --- src/components/HabitForm.test.tsx | 78 +++++++++++++++++++ src/components/HabitForm.tsx | 37 +++++++-- .../design-system/HabitColorPicker.tsx | 22 +++++- 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/components/HabitForm.test.tsx diff --git a/src/components/HabitForm.test.tsx b/src/components/HabitForm.test.tsx new file mode 100644 index 0000000..4a53466 --- /dev/null +++ b/src/components/HabitForm.test.tsx @@ -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(); +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)[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("button")].find((element) => element.textContent?.trim() === text)!; +} + +test("habit starters remain editable and custom creation resets the draft", async () => { + await act(async () => root.render( {}} onCreated={() => {}} onExpired={() => {}} />)); + await act(async () => button("Reading").click()); + expect(container.querySelector('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read"); + expect(container.querySelector("select")?.value).toBe("count"); + expect(container.querySelector('input[type="number"]')?.value).toBe("10"); + await act(async () => button("Evening routine").click()); + expect(container.querySelector("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('input[placeholder="e.g. Read ten pages"]')?.value).toBe(""); + expect(container.querySelector("select")?.value).toBe("manual"); +}); + +test("compact palette discloses full controls and preserves custom color", async () => { + function Picker() { + const [color, setColor] = useState("#123456"); + return ; + } + await act(async () => root.render()); + 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('input[type="text"]')?.value).toBe("#123456"); + await act(async () => button("Fewer colors").click()); + expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull(); +}); diff --git a/src/components/HabitForm.tsx b/src/components/HabitForm.tsx index c5946d6..e770009 100644 --- a/src/components/HabitForm.tsx +++ b/src/components/HabitForm.tsx @@ -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({ 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("input")?.focus(); + } + async function submit(event: FormEvent) { event.preventDefault(); if (saving.current) return; @@ -78,6 +96,15 @@ export function HabitForm({ Make it yours.} 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")}>
+
+

Start with an idea, then make it your own.

+
+ {STARTERS.map((starter) => ( + + ))} + +
+
{(id) => ( - + )} {method === "count" && (
- + {(id) => ( - +
{error && (

diff --git a/src/components/design-system/HabitColorPicker.tsx b/src/components/design-system/HabitColorPicker.tsx index 78a8cae..092e66f 100644 --- a/src/components/design-system/HabitColorPicker.tsx +++ b/src/components/design-system/HabitColorPicker.tsx @@ -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 (

Habit color

- 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."}

+ {compact && ( + <> +
+ {HABIT_PALETTES.map((palette) => ( + + ))} +
+ + + )} +