Center each habit layout on its completion action

This commit is contained in:
syntaxbullet
2026-09-05 08:06:16 +02:00
parent 3945998fa8
commit b832b5734e
6 changed files with 87 additions and 12 deletions

View File

@@ -276,6 +276,16 @@ describe("homepage", () => {
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
await render("/");
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
const countGoal = container.querySelector(".ds-goal--count")!;
expect(countGoal.querySelector('.ds-goal-count input[aria-label="Total Water"]')).not.toBeNull();
expect(countGoal.querySelector(".ds-goal-unit")?.textContent).toBe("glasses");
expect(countGoal.textContent).not.toContain("Count target");
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
expect(container.querySelector('.ds-goal--manual h3 label')?.getAttribute("for")).toBe(checkIn.id);
expect(checkIn.getAttribute("aria-label")).toBe("Reading done");
expect(container.querySelector('.ds-goal--tasks details')).toBeNull();
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)).toBeNull();

View File

@@ -141,7 +141,7 @@ export function HabitHistory({
/>
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
<div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}
@@ -151,6 +151,8 @@ export function HabitHistory({
return (
<HabitChart
id={`history-${habit.habitId}`}
trackingMethod={habit.requirements?.method ?? habit.method ?? undefined}
checkInId={`habit-check-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"

View File

@@ -21,6 +21,8 @@ export function HabitChart({
due = true,
id,
headingLevel = 4,
trackingMethod,
checkInId,
}: {
name: string;
method: string;
@@ -38,9 +40,33 @@ export function HabitChart({
due?: boolean;
id?: string;
headingLevel?: 3 | 4;
trackingMethod?: "manual" | "count" | "tasks";
checkInId?: string;
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-title">
{trackingMethod === "manual" && children}
<Heading id={headingId} className="type-title">
{trackingMethod === "manual" ? <label htmlFor={checkInId}>{name}</label> : name}
</Heading>
</div>
{headingActions}
</header>
{(!due || schedule !== "Every day") && <p className="ds-goal-schedule">{due ? schedule : `Not scheduled today · ${schedule}`}</p>}
{editor}
{trackingMethod === "count" && <div className="ds-goal-count">{children}<span className="ds-goal-unit">{unit}</span></div>}
{trackingMethod === "tasks" && <div className="ds-goal-tasks">
{due && target > 0 && <p className="ds-goal-task-progress" role="status">{value} / {target} completed</p>}
{tasks}
</div>}
{calendar}
</section>
);
return (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-habit-chart-heading">

View File

@@ -56,19 +56,20 @@ export function SectionHeading({
export function Checkbox({
label,
description,
hideLabel = false,
className = "",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string; hideLabel?: boolean }) {
const descriptionId = useId();
return (
<label className={`ds-checkbox ${className}`}>
<input
type="checkbox"
aria-label={description ? label : undefined}
aria-label={description || hideLabel ? label : undefined}
{...props}
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
/>
<span className="ds-checkbox-copy">
<span className={`ds-checkbox-copy${hideLabel ? " ds-visually-hidden" : ""}`}>
<span className="ds-checkbox-label">{label}</span>
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
</span>

View File

@@ -510,6 +510,7 @@ function SavedHabit({
const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null);
const [addingTask, setAddingTask] = useState(false);
const blocked = disabled || !habit.due;
const method = habit.requirements?.method ?? habit.method;
return (
<HabitHistory
habit={habit}
@@ -538,6 +539,7 @@ function SavedHabit({
tasks={
habit.requirements?.method === "tasks" ? (
<>
<ul className="ds-routine-items">
{habit.requirements.tasks.map((config) => {
const occurrence = habit.tasks.find((task) => task.taskId === config.id);
return (
@@ -556,6 +558,7 @@ function SavedHabit({
/>
);
})}
</ul>
{addingTask && (
<InlineTaskEditor
name=""
@@ -571,9 +574,6 @@ function SavedHabit({
/>
)}
<div className="ds-task-add-action">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
aria-haspopup="dialog"
@@ -588,7 +588,7 @@ function SavedHabit({
) : undefined
}
>
{habit.method === "count" ? (
{method === "count" ? (
<Counter
key={date}
label={habit.name ?? "habit count"}
@@ -597,8 +597,10 @@ function SavedHabit({
disabled={blocked}
onChange={(count) => void onUpdate(habit, { count })}
/>
) : habit.method === "manual" ? (
) : method === "manual" ? (
<Checkbox
id={`habit-check-${habit.habitId}`}
hideLabel
label={`${habit.name} done`}
checked={habit.complete}
disabled={blocked}
@@ -634,11 +636,11 @@ function SavedTask({
}) {
const [mode, setMode] = useState<ItemMode | null>(null);
return (
<div className="ds-editable-task">
<li className="ds-editable-task">
<div className="ds-editable-task-row">
<Checkbox
label={task.name}
description={`${scheduleLabel(schedule)}${scheduled ? "" : " · Not scheduled today"}`}
description={!scheduled ? "Not scheduled today" : schedule.type === "daily" ? undefined : scheduleLabel(schedule)}
checked={task.done}
disabled={blocked || !!mode}
onChange={(event) => onCheck(event.target.checked)}
@@ -664,6 +666,6 @@ function SavedTask({
onClose={() => setMode(null)}
/>
)}
</div>
</li>
);
}

View File

@@ -1588,3 +1588,37 @@
.ds-habit-filters .ds-button[aria-pressed="true"] { border-color: Highlight; }
.ds-counter-input { border-color: ButtonText; }
}
/* Each goal puts its completion control before secondary history. */
.ds-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.ds-habit-list > .ds-goal { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 24px 0; }
.ds-goal-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.ds-goal-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.ds-goal-title h3 { overflow-wrap: anywhere; min-width: 0; }
.ds-goal-title label { cursor: pointer; }
.ds-goal-title > .ds-checkbox { flex: 0 0 44px; justify-content: center; gap: 0; }
.ds-goal--manual .ds-goal-title:has(input:checked) h3 { color: var(--ds-secondary); }
.ds-goal-schedule { @apply type-small; color: var(--ds-secondary); margin-top: 4px; }
.ds-goal--manual .ds-goal-schedule { padding-left: 56px; }
.ds-goal-count { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; }
.ds-goal-count .ds-counter { padding: 6px; gap: 8px; background: var(--ds-surface); }
.ds-goal-count .ds-counter-input { @apply type-title; width: 5ch; min-height: 52px; background: var(--ds-paper); }
.ds-goal-count .ds-counter .ds-button { min-height: 48px; min-width: 48px; }
.ds-goal-unit { @apply type-body; color: var(--ds-secondary); overflow-wrap: anywhere; }
.ds-habit-list .ds-goal .ds-habit-history { align-self: flex-end; margin-top: 8px; }
.ds-habit-list .ds-goal .ds-habit-history:has([aria-expanded="true"]) { align-self: stretch; }
.ds-goal-task-progress { @apply type-small; color: var(--ds-secondary); margin: 8px 0; }
.ds-routine-items { list-style: none; padding: 0; margin: 0; }
.ds-routine-items .ds-editable-task:first-child { border-top: 0; }
.ds-routine-items .ds-editable-task-row { padding: 4px 8px; gap: 12px; }
.ds-routine-items .ds-editable-task-row > .ds-checkbox { min-height: 48px; }
.ds-routine-items .ds-editable-task-row:has(input:checked) .ds-checkbox-label { color: var(--ds-secondary); text-decoration: line-through; text-decoration-color: var(--ds-rule); }
.ds-routine-items .ds-editable-task-row:hover { background: var(--ds-surface); }
.ds-goal-tasks .ds-task-add-action { padding-bottom: 0; }
.ds-habit-starter-options { margin-top: 0; }
@media (max-width: 700px) {
.ds-goal-heading { gap: 8px; }
.ds-goal-count { gap: 8px; }
.ds-goal-count .ds-counter { gap: 4px; }
.ds-habit-list .ds-goal .ds-habit-history { align-self: flex-start; }
}