Use shared modals and combine selected-day actions
This commit is contained in:
14
README.md
14
README.md
@@ -60,15 +60,19 @@ right. It stacks on narrow screens and shows an initial if the avatar is unavail
|
|||||||
|
|
||||||
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
||||||
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
||||||
counts exclude days off. Inline controls beside habit headings edit the name,
|
counts exclude days off. Controls beside habit headings open a modal to edit the name,
|
||||||
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
||||||
Each task can be renamed, rescheduled, or deleted in place; Add task also supports
|
Each task can be renamed, rescheduled, or deleted in a modal; Add task also supports
|
||||||
its own recurrence. Both habit and task schedules support daily, selected weekdays,
|
its own recurrence. Both habit and task schedules support daily, selected weekdays,
|
||||||
day intervals, and week intervals with an editable start date and weekday.
|
day intervals, and week intervals with an editable start date and weekday.
|
||||||
Archive asks for confirmation in place and preserves earlier history.
|
Archive asks for confirmation in a modal and preserves earlier history.
|
||||||
**Archived habits** opens the archive, with history inspection and restoration
|
**Archived habits** opens the archive modal, with history inspection and restoration
|
||||||
starting today. Task deletion still uses an in-place confirmation. Tasks
|
starting today. Task deletion uses the same modal confirmation. Tasks
|
||||||
remain manageable on days off, while their checkboxes stay disabled.
|
remain manageable on days off, while their checkboxes stay disabled.
|
||||||
|
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
|
||||||
|
Dialogs keep the background inactive, contain keyboard focus, support Escape and
|
||||||
|
backdrop dismissal, and return focus to their opener. Long content scrolls beneath
|
||||||
|
a fixed header; closing is disabled during saves.
|
||||||
Mutations are saved through the authenticated API; failed
|
Mutations are saved through the authenticated API; failed
|
||||||
saves retain the recorded values and allow retry. Account and calendar failures
|
saves retain the recorded values and allow retry. Account and calendar failures
|
||||||
have retry states. The dashboard refreshes on window focus and every minute while
|
have retry states. The dashboard refreshes on window focus and every minute while
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReminderSettings } from "./components/ReminderSettings";
|
import { ReminderSettings } from "./components/ReminderSettings";
|
||||||
|
import { Modal } from "./components/design-system/Modal";
|
||||||
import {
|
import {
|
||||||
afterAll,
|
afterAll,
|
||||||
afterEach,
|
afterEach,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
test,
|
test,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { act } from "react";
|
import { act, StrictMode, useState } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import { App } from "./App";
|
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)!;
|
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
|
||||||
|
|
||||||
describe("homepage", () => {
|
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 () => {
|
test("archived habits expose history and restore to the dashboard", async () => {
|
||||||
const f = connectAccount();
|
const f = connectAccount();
|
||||||
f.setTime("2026-09-03T12:00:00Z");
|
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 f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
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());
|
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")!;
|
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||||
expect(past).toBeDefined();
|
expect(past).toBeDefined();
|
||||||
|
|||||||
@@ -12,37 +12,38 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
|||||||
const [confirmation, setConfirmation] = useState("");
|
const [confirmation, setConfirmation] = useState("");
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const listId = useId();
|
const listId = useId();
|
||||||
async function run(action: () => Promise<unknown>) {
|
async function run(action: () => Promise<unknown>) {
|
||||||
if (saving.current) return;
|
if (saving.current || reminderBusy) return;
|
||||||
saving.current = true; setBusy(true); setError("");
|
saving.current = true; setBusy(true); setError("");
|
||||||
try { await action(); onChanged(); }
|
try { await action(); onChanged(); }
|
||||||
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||||
finally { saving.current = false; setBusy(false); }
|
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>}>
|
<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 }) })); }}>
|
<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">
|
<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>
|
</Field>
|
||||||
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
<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>
|
</form>
|
||||||
</SettingRow>
|
</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>}>
|
<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>
|
<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>
|
||||||
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
<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 }) })); }}>
|
<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="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 operator’s retention policy.</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 operator’s 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>
|
<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 || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
|
<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>}
|
</form>}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<FormMessage error>{error}</FormMessage>
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
|||||||
setError(message); if (message.includes("session has expired")) expired.current();
|
setError(message); if (message.includes("session has expired")) expired.current();
|
||||||
} finally { saving.current = false; setBusy(false); }
|
} 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 you’re 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 you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||||
<FormMessage error>{error}</FormMessage>
|
<FormMessage error>{error}</FormMessage>
|
||||||
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
{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>}
|
{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>}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashbo
|
|||||||
import { Button, Checkbox } from "./design-system/primitives";
|
import { Button, Checkbox } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
|
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
|
||||||
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void;
|
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [day, setDay] = useState<TodayHabit | null>(null);
|
const [day, setDay] = useState<TodayHabit | null>(null);
|
||||||
const [count, setCount] = useState("");
|
const [count, setCount] = useState("");
|
||||||
@@ -28,7 +28,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
|
|||||||
}, [habitId, date, attempt]);
|
}, [habitId, date, attempt]);
|
||||||
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
|
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
|
||||||
if (saving.current || disabled) return;
|
if (saving.current || disabled) return;
|
||||||
saving.current = true; setBusy(true); setError(""); setNotice("");
|
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
|
||||||
try {
|
try {
|
||||||
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
|
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
|
||||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Could not save. Try again.";
|
const message = error instanceof Error ? error.message : "Could not save. Try again.";
|
||||||
setError(message); if (message.includes("session has expired")) expired.current?.();
|
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}`}>
|
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>
|
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { Button, SectionHeading } from "./design-system/primitives";
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
import { Button } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||||
@@ -30,12 +31,6 @@ export function HabitForm({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current) return;
|
if (saving.current) return;
|
||||||
@@ -80,23 +75,7 @@ export function HabitForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<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")}>
|
||||||
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 account’s
|
|
||||||
timezone.
|
|
||||||
</SectionHeading>
|
|
||||||
<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}>
|
||||||
<Field label="Habit name">
|
<Field label="Habit name">
|
||||||
@@ -223,6 +202,6 @@ export function HabitForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Modal } from "./design-system/Modal";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { CalendarResponse } from "../shared/calendar";
|
import type { CalendarResponse } from "../shared/calendar";
|
||||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
@@ -40,6 +42,7 @@ export function HabitHistory({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedDate, setSelectedDate] = useState<string>();
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
const [editingDay, setEditingDay] = useState(false);
|
const [editingDay, setEditingDay] = useState(false);
|
||||||
|
const [savingDay, setSavingDay] = useState(false);
|
||||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@@ -125,6 +128,7 @@ export function HabitHistory({
|
|||||||
color={calendar.settings.mainColor}
|
color={calendar.settings.mainColor}
|
||||||
emptyColor={calendar.settings.emptyColor}
|
emptyColor={calendar.settings.emptyColor}
|
||||||
days={days}
|
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={
|
legend={
|
||||||
<CalendarLegend
|
<CalendarLegend
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
@@ -134,16 +138,12 @@ export function HabitHistory({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="ds-action-row ds-history-actions">
|
{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}>
|
||||||
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
|
<div className="ds-history-editor">
|
||||||
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
|
<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>
|
||||||
</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>
|
|
||||||
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||||
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||||
</div>}
|
</div></Modal>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (calendarOnly) return chart;
|
if (calendarOnly) return chart;
|
||||||
|
|||||||
@@ -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 { Button } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
export type ItemMode = "edit" | "delete";
|
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 }: {
|
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
|
||||||
name: string;
|
name: string;
|
||||||
kind: "habit" | "task";
|
kind: "habit" | "task";
|
||||||
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
useEffect(() => {
|
const id = useId();
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
||||||
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
|
|
||||||
return () => { if (previous?.isConnected) previous.focus(); };
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
return (
|
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}`}
|
<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)'}>
|
||||||
onKeyDown={(event) => {
|
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
|
||||||
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
|
|
||||||
}}
|
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current || disabled) return;
|
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>}
|
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
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",
|
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 [settings, setSettings] = useState<ReminderResponse>();
|
||||||
const [draft, setDraft] = useState(defaultReminder);
|
const [draft, setDraft] = useState(defaultReminder);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -26,26 +26,26 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
|
|||||||
}, [attempt]);
|
}, [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></>}>
|
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 => {
|
{!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 form = event.currentTarget;
|
||||||
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
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 {
|
try {
|
||||||
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
||||||
setDraft(submitted);
|
setDraft(submitted);
|
||||||
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
|
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
} 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>}
|
{!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 }))} />
|
<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">
|
<div className="ds-clock-fields">
|
||||||
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
|
{([['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>)}
|
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
|
||||||
</div>
|
</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>
|
<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>
|
</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>}
|
{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>}
|
</form>}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { Modal } from "./design-system/Modal";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 { Button, Checkbox } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
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 [sendError, setSendError] = useState("");
|
||||||
const lock = useRef(false);
|
const lock = useRef(false);
|
||||||
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
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 key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
||||||
const ready = card?.key === key ? card : null;
|
const ready = card?.key === key ? card : null;
|
||||||
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
||||||
const busy = sending;
|
const busy = sending;
|
||||||
useEffect(() => { heading.current?.focus(); }, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setConnectionError("");
|
setConnectionError("");
|
||||||
@@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
finally { lock.current = false; setSending(false); }
|
finally { lock.current = false; setSending(false); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
|
<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")}>
|
||||||
<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>
|
|
||||||
<div className="ds-share-layout">
|
<div className="ds-share-layout">
|
||||||
<div className="ds-share-controls">
|
<div className="ds-share-controls">
|
||||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
<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 didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
{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 didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function CalendarHeatmap({
|
|||||||
onSelectDate,
|
onSelectDate,
|
||||||
historyLabel,
|
historyLabel,
|
||||||
legend,
|
legend,
|
||||||
|
selectedDayAction,
|
||||||
}: {
|
}: {
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
|
|||||||
onSelectDate?: (date: string) => void;
|
onSelectDate?: (date: string) => void;
|
||||||
historyLabel?: string;
|
historyLabel?: string;
|
||||||
legend?: ReactNode;
|
legend?: ReactNode;
|
||||||
|
selectedDayAction?: (date: string) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [months, setMonths] = useState<MonthView | "custom">(12);
|
const [months, setMonths] = useState<MonthView | "custom">(12);
|
||||||
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
||||||
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
<div id={inspectorId} className="ds-date-inspector">
|
||||||
<div className="ds-date-inspector-heading">
|
<div className="ds-date-inspector-heading" aria-live="polite">
|
||||||
<CalendarDays size={22} aria-hidden="true" />
|
<CalendarDays size={22} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||||
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
|
|||||||
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
80
src/components/design-system/Modal.tsx
Normal file
80
src/components/design-system/Modal.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
import { useId, type ReactNode } from "react";
|
import { useId, type ReactNode } from "react";
|
||||||
import { X } from "lucide-react";
|
import { Modal } from "./Modal";
|
||||||
import { Button } from "./primitives";
|
|
||||||
|
|
||||||
/** Shared flat panel for secondary workspace flows. */
|
/** Secondary workspace flows share one modal presentation. */
|
||||||
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: {
|
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;
|
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}>
|
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
||||||
<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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consistent label, explanation and control columns for preference forms. */
|
/** Consistent label, explanation and control columns for preference forms. */
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
const [archive, setArchive] = useState(false);
|
const [archive, setArchive] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [needsRefresh, setNeedsRefresh] = useState(false);
|
const [needsRefresh, setNeedsRefresh] = useState(false);
|
||||||
|
const focusAddAfterRefresh = useRef(false);
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
const expired = useRef(onExpired);
|
const expired = useRef(onExpired);
|
||||||
@@ -207,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
if (!controller.signal.aborted) reportError(error);
|
if (!controller.signal.aborted) reportError(error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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();
|
return () => controller.abort();
|
||||||
}, [attempt, reportError]);
|
}, [attempt, reportError]);
|
||||||
@@ -361,6 +368,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
<div className="ds-action-row ds-primary-actions">
|
<div className="ds-action-row ds-primary-actions">
|
||||||
<Button
|
<Button
|
||||||
id="add-habit"
|
id="add-habit"
|
||||||
|
aria-haspopup="dialog"
|
||||||
disabled={adding || busy || loading || needsRefresh}
|
disabled={adding || busy || loading || needsRefresh}
|
||||||
onClick={() => setAdding(true)}
|
onClick={() => setAdding(true)}
|
||||||
aria-expanded={adding}
|
aria-expanded={adding}
|
||||||
@@ -370,14 +378,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</Button>
|
</Button>
|
||||||
{today.habits.length > 0 && (
|
{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>
|
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-action-row ds-utility-row">
|
<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-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-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={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -410,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
onExpired={() => expired.current()}
|
onExpired={() => expired.current()}
|
||||||
onCreated={(name) => {
|
onCreated={(name) => {
|
||||||
|
focusAddAfterRefresh.current = true;
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
setNotice(`${name} created.`);
|
setNotice(`${name} created.`);
|
||||||
setAttempt((value) => value + 1);
|
setAttempt((value) => value + 1);
|
||||||
@@ -536,7 +545,7 @@ function SavedHabit({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{addingTask ? (
|
{addingTask && (
|
||||||
<InlineTaskEditor
|
<InlineTaskEditor
|
||||||
name=""
|
name=""
|
||||||
schedule={{ type: "daily" }}
|
schedule={{ type: "daily" }}
|
||||||
@@ -549,20 +558,21 @@ function SavedHabit({
|
|||||||
onSave={(patch) => onManage(habit, patch, undefined, true)}
|
onSave={(patch) => onManage(habit, patch, undefined, true)}
|
||||||
onDelete={async () => {}}
|
onDelete={async () => {}}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<div className="ds-task-add-action">
|
<div className="ds-task-add-action">
|
||||||
{!habit.requirements.tasks.length && (
|
{!habit.requirements.tasks.length && (
|
||||||
<p className="ds-footnote">Add a task to start checking in.</p>
|
<p className="ds-footnote">Add a task to start checking in.</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={addingTask}
|
||||||
disabled={disabled || habit.requirements.tasks.length >= 100}
|
disabled={disabled || habit.requirements.tasks.length >= 100}
|
||||||
onClick={() => setAddingTask(true)}
|
onClick={() => setAddingTask(true)}
|
||||||
>
|
>
|
||||||
Add task +
|
Add task +
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -993,6 +993,12 @@
|
|||||||
@apply type-small;
|
@apply type-small;
|
||||||
}
|
}
|
||||||
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.ds-date-inspector-actions { flex-shrink: 0; margin-left: auto; }
|
||||||
|
.ds-date-inspector:has(.ds-date-inspector-actions) { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-date-inspector:has(.ds-date-inspector-actions) { align-items: stretch; flex-direction: column; gap: 18px; }
|
||||||
|
.ds-date-inspector-actions { margin-left: 36px; }
|
||||||
|
}
|
||||||
.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
||||||
.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; }
|
.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; }
|
||||||
.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
||||||
@@ -1405,10 +1411,6 @@
|
|||||||
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
|
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
|
||||||
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
|
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
|
||||||
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
|
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
|
||||||
.ds-share-panel { margin-top: 32px; padding: 32px; border: 1px solid var(--ds-rule); }
|
|
||||||
.ds-share-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 32px; }
|
|
||||||
.ds-share-heading h2 { margin-top: 8px; }
|
|
||||||
.ds-share-heading > .ds-button { flex: 0 0 auto; padding: 12px; }
|
|
||||||
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
|
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
|
||||||
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
|
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
|
||||||
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
|
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
|
||||||
@@ -1426,7 +1428,6 @@
|
|||||||
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||||
.ds-share-panel { padding: 20px; }
|
|
||||||
.ds-share-preview { padding: 12px; }
|
.ds-share-preview { padding: 12px; }
|
||||||
}
|
}
|
||||||
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||||
@@ -1444,10 +1445,6 @@
|
|||||||
.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; }
|
.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; }
|
||||||
.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; }
|
.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; }
|
||||||
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
|
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
|
||||||
.ds-workspace-panel { border: 1px solid var(--ds-rule); padding: 32px; margin: 32px 0 40px; min-width: 0; }
|
|
||||||
.ds-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
|
|
||||||
.ds-panel-heading > div { min-width: 0; }
|
|
||||||
.ds-panel-heading h2 { margin-top: 10px; @apply type-section; overflow-wrap: anywhere; }
|
|
||||||
.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; }
|
.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; }
|
||||||
.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); }
|
.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); }
|
||||||
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); }
|
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); }
|
||||||
@@ -1492,9 +1489,6 @@
|
|||||||
.ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
|
.ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ds-workspace-panel { padding: 20px; margin: 24px 0 32px; }
|
|
||||||
.ds-panel-heading { gap: 16px; margin-bottom: 24px; }
|
|
||||||
.ds-panel-heading h2 { @apply type-title; }
|
|
||||||
.ds-panel-description { @apply type-small; }
|
.ds-panel-description { @apply type-small; }
|
||||||
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
|
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
|
||||||
.ds-setting-heading > div { max-width: none; }
|
.ds-setting-heading > div { max-width: none; }
|
||||||
@@ -1505,3 +1499,26 @@
|
|||||||
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
|
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }
|
||||||
|
|
||||||
|
/* Shared top-layer modals keep temporary flows out of the dashboard layout. */
|
||||||
|
.ds-modal { width: min(1040px, calc(100vw - 64px)); max-width: none; max-height: calc(100dvh - 64px); margin: auto; padding: 0; border: 1px solid var(--ds-rule); background: var(--ds-paper); color: var(--ds-ink); overflow: hidden; box-shadow: 0 24px 80px #00000026; }
|
||||||
|
.ds-modal[open] { display: flex; flex-direction: column; }
|
||||||
|
.ds-modal--compact { width: min(660px, calc(100vw - 64px)); }
|
||||||
|
.ds-modal::backdrop { background: #11111166; backdrop-filter: blur(3px); }
|
||||||
|
.ds-modal-heading { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: flex-start; gap: 24px; padding: 28px 32px 24px; border-bottom: 1px solid var(--ds-rule); }
|
||||||
|
.ds-modal-heading > div { min-width: 0; }
|
||||||
|
.ds-modal-heading h2 { margin-top: 8px; @apply type-section; overflow-wrap: anywhere; }
|
||||||
|
.ds-modal-heading h2:focus { outline: none; }
|
||||||
|
.ds-modal-heading .ds-panel-description { @apply type-small; }
|
||||||
|
.ds-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 28px 32px 32px; scrollbar-gutter: stable; }
|
||||||
|
.ds-modal-body > .ds-setting-row:first-child { border-top: 0; padding-top: 0; }
|
||||||
|
.ds-modal-body > .ds-record-list > .ds-record:first-child { border-top: 0; }
|
||||||
|
.ds-modal-body > .ds-record-list > .ds-record:first-child > .ds-record-heading { padding-top: 0; }
|
||||||
|
.ds-modal-form { min-width: 0; }
|
||||||
|
.ds-modal-body > .ds-history-editor { border: 0; background: transparent; padding: 0; margin: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-modal, .ds-modal--compact { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
|
||||||
|
.ds-modal-heading { padding: 20px; gap: 16px; }
|
||||||
|
.ds-modal-heading h2 { @apply type-title; }
|
||||||
|
.ds-modal-body { padding: 20px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user