Simplify habit creation with editable starters and compact colors

This commit is contained in:
syntaxbullet
2026-09-05 07:51:22 +02:00
parent a2f5616b96
commit 9f8154e412
3 changed files with 130 additions and 7 deletions

View 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();
});

View File

@@ -7,6 +7,12 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
import { habitInput, type Schedule } from "../habits/contracts"; import { habitInput, type Schedule } from "../habits/contracts";
import { habitRequest } from "../lib/dashboard"; 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. */ /** Account behavior composed from the design system's existing form components. */
export function HabitForm({ export function HabitForm({
date, date,
@@ -23,14 +29,26 @@ export function HabitForm({
const saving = useRef(false); const saving = useRef(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual"); const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
const [target, setTarget] = useState(8); const [target, setTarget] = useState(10);
const [unit, setUnit] = useState("glasses"); const [unit, setUnit] = useState("pages");
const [tasks, setTasks] = useState([""]); const [tasks, setTasks] = useState([""]);
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" }); const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
const [color, setColor] = useState("#58765b"); const [color, setColor] = useState("#58765b");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); 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) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (saving.current) return; 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 accounts timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}> <Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your accounts 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}> <form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}> <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"> <Field label="Habit name">
{(id) => ( {(id) => (
<input <input
@@ -101,13 +128,13 @@ export function HabitForm({
> >
<option value="manual">A simple check-in</option> <option value="manual">A simple check-in</option>
<option value="count">A count target</option> <option value="count">A count target</option>
<option value="tasks">A list of tasks</option> <option value="tasks">A routine checklist</option>
</select> </select>
)} )}
</Field> </Field>
{method === "count" && ( {method === "count" && (
<div className="ds-form-grid"> <div className="ds-form-grid">
<Field label="Daily target"> <Field label="Target per scheduled day">
{(id) => ( {(id) => (
<input <input
id={id} id={id}
@@ -186,7 +213,7 @@ export function HabitForm({
onChange={setSchedule} onChange={setSchedule}
anchorDate={date} anchorDate={date}
/> />
<HabitColorPicker value={color} onChange={setColor} mode="create" /> <HabitColorPicker value={color} onChange={setColor} mode="create" compact />
</fieldset> </fieldset>
{error && ( {error && (
<p className="ds-form-feedback" role="alert"> <p className="ds-form-feedback" role="alert">

View File

@@ -1,4 +1,4 @@
import { useId } from "react"; import { useId, useState } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model"; import { HABIT_COLORS, progressShade } from "./calendar-model";
import { Field } from "./Field"; import { Field } from "./Field";
@@ -41,19 +41,36 @@ export function HabitColorPicker({
value, value,
onChange, onChange,
mode = "demo", mode = "demo",
compact = false,
}: { }: {
value: string; value: string;
onChange: (color: string) => void; onChange: (color: string) => void;
mode?: "demo" | "create" | "edit"; mode?: "demo" | "create" | "edit";
compact?: boolean;
}) { }) {
const id = useId(); const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
const valid = isHabitColor(value); const valid = isHabitColor(value);
return ( return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}> <fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend> <legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote"> <p id={`${id}-hint`} className="ds-footnote">
Choose a suggested shade or enter a custom color for this habits calendar. {showDetails ? "Choose a suggested shade or enter a custom color for this habits calendar." : "Choose a color for your habit."}
</p> </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"> <div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => ( {HABIT_PALETTES.map((palette) => (
<div <div
@@ -112,6 +129,7 @@ export function HabitColorPicker({
)} )}
</Field> </Field>
</div> </div>
</div>
{valid && ( {valid && (
<div <div
className="ds-color-live-preview" className="ds-color-live-preview"