diff --git a/packages/client/src/components/modal.css.ts b/packages/client/src/components/modal.css.ts index 1665edf..1690665 100644 --- a/packages/client/src/components/modal.css.ts +++ b/packages/client/src/components/modal.css.ts @@ -3,6 +3,8 @@ import { vars } from "../theme.css.ts"; const bp = { sm: "screen and (min-width: 480px)", + md: "screen and (min-width: 768px)", + lg: "screen and (min-width: 1200px)", }; const fadeIn = keyframes({ @@ -71,6 +73,12 @@ export const content = style({ paddingBlock: vars.space.lg, paddingInline: vars.space.lg, }, + [bp.md]: { + maxWidth: "720px", + }, + [bp.lg]: { + maxWidth: "880px", + }, }, }); diff --git a/packages/client/src/components/modal.tsx b/packages/client/src/components/modal.tsx index e3a5df0..1c94084 100644 --- a/packages/client/src/components/modal.tsx +++ b/packages/client/src/components/modal.tsx @@ -3,18 +3,23 @@ import type { Signal } from "@preact/signals"; import type { ComponentChildren } from "preact"; import * as s from "./modal.css.ts"; +const DISCARD_PROMPT = "Discard unsaved changes?"; + export function Modal({ open, onClose, title, + isDirty, children, }: { open: Signal; onClose: () => void; title: string; + isDirty?: () => boolean; children: ComponentChildren; }) { const dialogRef = useRef(null); + const mouseDownOnBackdrop = useRef(false); useEffect(() => { const el = dialogRef.current; @@ -26,20 +31,58 @@ export function Modal({ } }, [open.value]); - // Close on backdrop click - const handleClick = (e: MouseEvent) => { - if (e.target === dialogRef.current) { - onClose(); + const checkDirty = () => { + if (isDirty) return isDirty(); + const el = dialogRef.current; + if (!el) return false; + const fields = el.querySelectorAll("input, textarea"); + for (const field of fields) { + if (field instanceof HTMLInputElement) { + const skip = ["hidden", "submit", "button", "reset", "checkbox", "radio"]; + if (skip.includes(field.type)) continue; + } + if (field.value !== field.defaultValue) return true; + } + return false; + }; + + const tryClose = () => { + if (checkDirty() && !window.confirm(DISCARD_PROMPT)) { + return; + } + onClose(); + }; + + const handleMouseDown = (e: MouseEvent) => { + mouseDownOnBackdrop.current = e.target === dialogRef.current; + }; + + const handleMouseUp = (e: MouseEvent) => { + const wasOnBackdrop = mouseDownOnBackdrop.current; + mouseDownOnBackdrop.current = false; + if (wasOnBackdrop && e.target === dialogRef.current) { + tryClose(); } }; + const handleCancel = (e: Event) => { + e.preventDefault(); + tryClose(); + }; + return ( - + {open.value && (
{title} -
diff --git a/packages/feature-requests/src/ui/pages/feature-requests.tsx b/packages/feature-requests/src/ui/pages/feature-requests.tsx index ccfa015..0b2b2a8 100644 --- a/packages/feature-requests/src/ui/pages/feature-requests.tsx +++ b/packages/feature-requests/src/ui/pages/feature-requests.tsx @@ -1,4 +1,4 @@ -import { useSignal } from "@preact/signals"; +import { useSignal, useSignalEffect, type Signal } from "@preact/signals"; import { auth } from "@exosphere/client/auth"; import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; import { Link } from "@exosphere/client/link"; @@ -21,7 +21,13 @@ import { SortControls } from "../components/sort-controls.tsx"; import { useSortParams } from "../hooks/use-sort-params.ts"; import { useEffect } from "preact/hooks"; -function SubmitForm({ onCreated }: { onCreated: () => void }) { +function SubmitForm({ + onCreated, + dirtyRef, +}: { + onCreated: () => void; + dirtyRef?: Signal; +}) { const title = useSignal(""); const description = useSignal(""); const selectedLabelIds = useSignal([]); @@ -37,6 +43,14 @@ function SubmitForm({ onCreated }: { onCreated: () => void }) { .catch(() => {}); }, []); + useSignalEffect(() => { + if (!dirtyRef) return; + dirtyRef.value = + title.value.trim() !== "" || + description.value.trim() !== "" || + selectedLabelIds.value.length > 0; + }); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -150,6 +164,7 @@ export function FeatureRequestsListPage() { const { activeTab, statuses } = useActiveTab(); const { query } = useLocation(); const showForm = useSignal(query?.new === "1"); + const formDirty = useSignal(false); const { sortBy, sortOrder } = useSortParams(); // Sync with query param changes (e.g. clicking "New" on the dashboard after arriving here). @@ -249,8 +264,13 @@ export function FeatureRequestsListPage() { {activeTab === "requests" && ( - (showForm.value = false)} title="New request"> - + (showForm.value = false)} + title="New request" + isDirty={() => formDirty.value} + > + )}
diff --git a/packages/kanban/src/ui/components/task-form.tsx b/packages/kanban/src/ui/components/task-form.tsx index 82c662f..c4d6a9f 100644 --- a/packages/kanban/src/ui/components/task-form.tsx +++ b/packages/kanban/src/ui/components/task-form.tsx @@ -1,4 +1,4 @@ -import { useSignal } from "@preact/signals"; +import { useSignal, useSignalEffect, type Signal } from "@preact/signals"; import * as ui from "@exosphere/client/ui.css"; import { LabelPicker, type LabelOption } from "@exosphere/client/components/label-picker"; import { getLabels } from "@exosphere/client/api/labels"; @@ -9,10 +9,12 @@ export function TaskForm({ columns, onCreated, initialStatus, + dirtyRef, }: { columns: KanbanColumnDef[]; onCreated: () => void; initialStatus?: string; + dirtyRef?: Signal; }) { const title = useSignal(""); const description = useSignal(""); @@ -30,6 +32,14 @@ export function TaskForm({ .catch(() => {}); }, []); + useSignalEffect(() => { + if (!dirtyRef) return; + dirtyRef.value = + title.value.trim() !== "" || + description.value.trim() !== "" || + selectedLabelIds.value.length > 0; + }); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); diff --git a/packages/kanban/src/ui/pages/board.tsx b/packages/kanban/src/ui/pages/board.tsx index 54a5af3..c608fba 100644 --- a/packages/kanban/src/ui/pages/board.tsx +++ b/packages/kanban/src/ui/pages/board.tsx @@ -20,6 +20,7 @@ export function BoardPage() { const { query } = useLocation(); const showForm = useSignal(query?.new === "1"); const formInitialStatus = useSignal(undefined); + const formDirty = useSignal(false); // Sync with query param changes (e.g. clicking "+ New" on the dashboard after arriving here). useEffect(() => { @@ -97,11 +98,17 @@ export function BoardPage() { - (showForm.value = false)} title="New task"> + (showForm.value = false)} + title="New task" + isDirty={() => formDirty.value} + >