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 { 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 account’s 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 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}>
|
<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">
|
||||||
|
|||||||
@@ -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 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>
|
</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"
|
||||||
|
|||||||
Reference in New Issue
Block a user