Use shared modals and combine selected-day actions

This commit is contained in:
syntaxbullet
2026-09-04 19:21:24 +02:00
parent 392deb9cbe
commit 892487eb4c
15 changed files with 242 additions and 113 deletions

View File

@@ -1,4 +1,5 @@
import { ReminderSettings } from "./components/ReminderSettings";
import { Modal } from "./components/design-system/Modal";
import {
afterAll,
afterEach,
@@ -10,7 +11,7 @@ import {
test,
} from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { act, StrictMode, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
@@ -117,6 +118,54 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => {
test("development Strict Mode does not restore background focus while a modal is open", async () => {
await act(async () => root.render(<StrictMode><Modal id="strict-modal" title="Editor" onClose={() => {}} initialFocus="input"><input aria-label="Draft" /></Modal></StrictMode>));
await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
expect(document.activeElement).toBe(container.querySelector("input"));
expect(container.querySelector<HTMLDialogElement>("dialog")?.open).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
});
test("settings open as a modal and Escape restores the opener and scrolling", async () => {
connectAccount();
await render("/");
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => dialog.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
await new Promise(resolve => dom.requestAnimationFrame(resolve));
expect(container.querySelector("dialog")).toBeNull();
expect(document.activeElement).toBe(opener);
expect(document.documentElement.style.overflow).toBe("");
});
test("modal dismissal waits for saves and nested dialogs retain the scroll lock", async () => {
function Harness() {
const [nested, setNested] = useState(true);
const [busy, setBusy] = useState(true);
return <Modal id="outer" title="Outer" onClose={() => {}}>
{nested && <Modal id="inner" title="Inner" busy={busy} onClose={() => setNested(false)}>
<button onClick={() => setBusy(false)}>Finish save</button>
</Modal>}
</Modal>;
}
await act(async () => root.render(<Harness />));
const inner = container.querySelector<HTMLDialogElement>("#inner")!;
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => root.render(null));
expect(document.documentElement.style.overflow).toBe("");
});
test("archived habits expose history and restore to the dashboard", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -143,6 +192,7 @@ describe("homepage", () => {
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();

View File

@@ -12,37 +12,38 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false);
const [reminderBusy, setReminderBusy] = useState(false);
const saving = useRef(false);
const [error, setError] = useState("");
const listId = useId();
async function run(action: () => Promise<unknown>) {
if (saving.current) return;
if (saving.current || reminderBusy) return;
saving.current = true; setBusy(true); setError("");
try { await action(); onChanged(); }
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={busy ? undefined : onClose} closeLabel="Close settings">
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone">
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy} onChange={event => setTimezone(event.target.value)} />}
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
</Field>
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || reminderBusy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
</form>
</SettingRow>
<ReminderSettings timezone={user.timezone} />
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
</SettingRow>
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
<p className="type-ui-heading">This cannot be undone.</p>
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operators retention policy.</p>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
<div className="ds-action-row"><Button type="submit" disabled={busy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy || reminderBusy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
<div className="ds-action-row"><Button type="submit" disabled={busy || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
</form>}
</SettingRow>
<FormMessage error>{error}</FormMessage>

View File

@@ -37,7 +37,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever youre ready." onClose={busy ? undefined : onClose} closeLabel="Close archive">
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever youre ready." onClose={onClose} busy={busy} closeLabel="Close archive">
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}

View File

@@ -4,8 +4,8 @@ import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashbo
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void;
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
}) {
const [day, setDay] = useState<TodayHabit | null>(null);
const [count, setCount] = useState("");
@@ -28,7 +28,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
}, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return;
saving.current = true; setBusy(true); setError(""); setNotice("");
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
@@ -37,7 +37,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.();
} finally { saving.current = false; setBusy(false); }
} finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}
return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Button, SectionHeading } from "./design-system/primitives";
import { Modal } from "./design-system/Modal";
import { useRef, useState, type FormEvent } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
@@ -30,12 +31,6 @@ export function HabitForm({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => previous?.focus();
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -80,23 +75,7 @@ export function HabitForm({
}
return (
<section
className="ds-section ds-split-section"
aria-labelledby="new-habit-title"
id="new-habit"
>
<SectionHeading
number="A NEW HABIT"
id="new-habit-title"
title={
<>
Make it <em>yours.</em>
</>
}
>
Choose what counts as complete. Your schedule follows your accounts
timezone.
</SectionHeading>
<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}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<Field label="Habit name">
@@ -223,6 +202,6 @@ export function HabitForm({
</Button>
</div>
</form>
</section>
</Modal>
);
}

View File

@@ -1,3 +1,5 @@
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -40,6 +42,7 @@ export function HabitHistory({
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
@@ -125,6 +128,7 @@ export function HabitHistory({
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
@@ -134,16 +138,12 @@ export function HabitHistory({
/>
}
/>
<div className="ds-action-row ds-history-actions">
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
</Button>
</div>
{editingDay && <div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" 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>
{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>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div>}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}
</>
);
if (calendarOnly) return chart;

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Modal } from "./design-system/Modal";
import { useId, useRef, useState, type ReactNode } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export type ItemMode = "edit" | "delete";
/** In-place editing and confirmation, shared by habit headings and task rows. */
/** Shared editing and confirmation dialogs for habit headings and task rows. */
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
name: string;
kind: "habit" | "task";
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const [draft, setDraft] = useState(name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, [mode]);
const id = useId();
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
return (
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onKeyDown={(event) => {
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}}
<Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onSubmit={async (event) => {
event.preventDefault();
if (saving.current || disabled) return;
@@ -74,5 +70,6 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div>
</form>
</Modal>
);
}

View File

@@ -9,7 +9,7 @@ const deliveryLabels: Record<string, string> = {
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
};
export function ReminderSettings({ timezone }: { timezone: string }) {
export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
const [settings, setSettings] = useState<ReminderResponse>();
const [draft, setDraft] = useState(defaultReminder);
const [error, setError] = useState("");
@@ -26,26 +26,26 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
}, [attempt]);
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders</p> : <form className="ds-form-stack" onSubmit={async event => {
event.preventDefault(); if (saving.current) return;
event.preventDefault(); if (saving.current || disabled) return;
const form = event.currentTarget;
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
saving.current = true; setBusy(true); setError(""); setNotice("");
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
setDraft(submitted);
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
finally { saving.current = false; setBusy(false); }
finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}}>
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy}>
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
<div className="ds-clock-fields">
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
</div>
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button></div>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
</fieldset>
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
</form>}

View File

@@ -1,5 +1,6 @@
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
import { Download, Send, X } from "lucide-react";
import { Download, Send } from "lucide-react";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { habitRequest, type TodayResponse } from "../lib/dashboard";
@@ -25,12 +26,10 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
const [sendError, setSendError] = useState("");
const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const heading = useRef<HTMLHeadingElement>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending;
useEffect(() => { heading.current?.focus(); }, []);
useEffect(() => {
const controller = new AbortController();
setConnectionError("");
@@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
finally { lock.current = false; setSending(false); }
}
return (
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
<header className="ds-share-heading">
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
</header>
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-share-layout">
<div className="ds-share-controls">
<fieldset className="ds-share-fieldset" disabled={busy}>
@@ -103,6 +98,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Discord didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>}
</div>
</div>
</section>
</Modal>
);
}

View File

@@ -24,6 +24,7 @@ export function CalendarHeatmap({
onSelectDate,
historyLabel,
legend,
selectedDayAction,
}: {
days: CalendarDay[];
unit: string;
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
</p>
</details>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<div className="ds-date-inspector-heading">
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div>
</div>
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
</div>
</div>
);

View File

@@ -0,0 +1,80 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./primitives";
let openModals = 0;
let previousOverflow = "";
/** Native top-layer dialog supplies focus containment and an inert background. */
export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: {
id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode;
onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const heading = useRef<HTMLHeadingElement>(null);
const pointerOnBackdrop = useRef(false);
const mounted = useRef(false);
useLayoutEffect(() => {
mounted.current = true;
const element = dialog.current!;
const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null;
if (!openModals++) {
previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = "hidden";
}
element.showModal();
const target = initialFocus ? element.querySelector<HTMLElement>(initialFocus) : heading.current;
target?.focus({ preventScroll: true });
return () => {
mounted.current = false;
element.close();
if (!--openModals) document.documentElement.style.overflow = previousOverflow;
// A successful save may keep its opener disabled until the dashboard
// refresh finishes. Restore focus when it becomes available again.
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
// Ignore React Strict Mode's setup/cleanup rehearsal.
if (mounted.current || !opener?.isConnected) return;
if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; }
const observer = new window.MutationObserver(() => {
if (opener.matches(":disabled")) return;
observer.disconnect();
window.clearTimeout(timeout);
if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true });
});
observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] });
const timeout = window.setTimeout(() => observer.disconnect(), 5000);
}));
};
}, []);
const dismiss = () => { if (!busy) onClose?.(); };
return <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; }
const active = document.activeElement;
if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault(); first.focus();
}
}}
onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }}
onClick={event => {
if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return;
const bounds = event.currentTarget.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss();
}}>
<header className="ds-modal-heading">
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
</header>
<div className="ds-modal-body">{children}</div>
</dialog>;
}

View File

@@ -1,18 +1,11 @@
import { useId, type ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./primitives";
import { Modal } from "./Modal";
/** Shared flat panel for secondary workspace flows. */
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: {
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string;
/** Secondary workspace flows share one modal presentation. */
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
}) {
return <section id={id} className="ds-workspace-panel" aria-label={title}>
<header className="ds-panel-heading">
<div><p className="ds-eyebrow">{eyebrow}</p><h2>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
{onClose && <Button variant="text" className="ds-panel-close" onClick={onClose} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>}
</header>
{children}
</section>;
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
}
/** Consistent label, explanation and control columns for preference forms. */

View File

@@ -172,6 +172,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [archive, setArchive] = useState(false);
const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false);
const focusAddAfterRefresh = useRef(false);
const saving = useRef(false);
const mounted = useRef(true);
const expired = useRef(onExpired);
@@ -207,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
if (!controller.signal.aborted) reportError(error);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
if (!controller.signal.aborted) {
setLoading(false);
if (focusAddAfterRefresh.current) {
focusAddAfterRefresh.current = false;
window.requestAnimationFrame(() => document.getElementById("add-habit")?.focus({ preventScroll: true }));
}
}
});
return () => controller.abort();
}, [attempt, reportError]);
@@ -361,6 +368,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
@@ -370,14 +378,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits </ButtonLink>
</>
)}
</div>
<div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div>
</>
)}
@@ -410,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
onCancel={() => setAdding(false)}
onExpired={() => expired.current()}
onCreated={(name) => {
focusAddAfterRefresh.current = true;
setAdding(false);
setNotice(`${name} created.`);
setAttempt((value) => value + 1);
@@ -536,7 +545,7 @@ function SavedHabit({
/>
);
})}
{addingTask ? (
{addingTask && (
<InlineTaskEditor
name=""
schedule={{ type: "daily" }}
@@ -549,20 +558,21 @@ function SavedHabit({
onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}}
/>
) : (
)}
<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"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
)}
</>
) : undefined
}