Center each habit layout on its completion action
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user