Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

ui: better edit modals

Hugo 81bb74ce 0d702384

+100 -12
+8
packages/client/src/components/modal.css.ts
··· 3 3 4 4 const bp = { 5 5 sm: "screen and (min-width: 480px)", 6 + md: "screen and (min-width: 768px)", 7 + lg: "screen and (min-width: 1200px)", 6 8 }; 7 9 8 10 const fadeIn = keyframes({ ··· 70 72 [bp.sm]: { 71 73 paddingBlock: vars.space.lg, 72 74 paddingInline: vars.space.lg, 75 + }, 76 + [bp.md]: { 77 + maxWidth: "720px", 78 + }, 79 + [bp.lg]: { 80 + maxWidth: "880px", 73 81 }, 74 82 }, 75 83 });
+49 -6
packages/client/src/components/modal.tsx
··· 3 3 import type { ComponentChildren } from "preact"; 4 4 import * as s from "./modal.css.ts"; 5 5 6 + const DISCARD_PROMPT = "Discard unsaved changes?"; 7 + 6 8 export function Modal({ 7 9 open, 8 10 onClose, 9 11 title, 12 + isDirty, 10 13 children, 11 14 }: { 12 15 open: Signal<boolean>; 13 16 onClose: () => void; 14 17 title: string; 18 + isDirty?: () => boolean; 15 19 children: ComponentChildren; 16 20 }) { 17 21 const dialogRef = useRef<HTMLDialogElement>(null); 22 + const mouseDownOnBackdrop = useRef(false); 18 23 19 24 useEffect(() => { 20 25 const el = dialogRef.current; ··· 26 31 } 27 32 }, [open.value]); 28 33 29 - // Close on backdrop click 30 - const handleClick = (e: MouseEvent) => { 31 - if (e.target === dialogRef.current) { 32 - onClose(); 34 + const checkDirty = () => { 35 + if (isDirty) return isDirty(); 36 + const el = dialogRef.current; 37 + if (!el) return false; 38 + const fields = el.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("input, textarea"); 39 + for (const field of fields) { 40 + if (field instanceof HTMLInputElement) { 41 + const skip = ["hidden", "submit", "button", "reset", "checkbox", "radio"]; 42 + if (skip.includes(field.type)) continue; 43 + } 44 + if (field.value !== field.defaultValue) return true; 45 + } 46 + return false; 47 + }; 48 + 49 + const tryClose = () => { 50 + if (checkDirty() && !window.confirm(DISCARD_PROMPT)) { 51 + return; 52 + } 53 + onClose(); 54 + }; 55 + 56 + const handleMouseDown = (e: MouseEvent) => { 57 + mouseDownOnBackdrop.current = e.target === dialogRef.current; 58 + }; 59 + 60 + const handleMouseUp = (e: MouseEvent) => { 61 + const wasOnBackdrop = mouseDownOnBackdrop.current; 62 + mouseDownOnBackdrop.current = false; 63 + if (wasOnBackdrop && e.target === dialogRef.current) { 64 + tryClose(); 33 65 } 34 66 }; 35 67 68 + const handleCancel = (e: Event) => { 69 + e.preventDefault(); 70 + tryClose(); 71 + }; 72 + 36 73 return ( 37 - <dialog ref={dialogRef} class={s.dialog} onClose={onClose} onClick={handleClick}> 74 + <dialog 75 + ref={dialogRef} 76 + class={s.dialog} 77 + onCancel={handleCancel} 78 + onMouseDown={handleMouseDown} 79 + onMouseUp={handleMouseUp} 80 + > 38 81 {open.value && ( 39 82 <div class={s.content}> 40 83 <div class={s.header}> 41 84 <span class={s.title}>{title}</span> 42 - <button type="button" class={s.closeBtn} onClick={onClose} aria-label="Close"> 85 + <button type="button" class={s.closeBtn} onClick={tryClose} aria-label="Close"> 43 86 &times; 44 87 </button> 45 88 </div>
+24 -4
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 - import { useSignal } from "@preact/signals"; 1 + import { useSignal, useSignalEffect, type Signal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; 4 4 import { Link } from "@exosphere/client/link"; ··· 21 21 import { useSortParams } from "../hooks/use-sort-params.ts"; 22 22 import { useEffect } from "preact/hooks"; 23 23 24 - function SubmitForm({ onCreated }: { onCreated: () => void }) { 24 + function SubmitForm({ 25 + onCreated, 26 + dirtyRef, 27 + }: { 28 + onCreated: () => void; 29 + dirtyRef?: Signal<boolean>; 30 + }) { 25 31 const title = useSignal(""); 26 32 const description = useSignal(""); 27 33 const selectedLabelIds = useSignal<string[]>([]); ··· 37 43 .catch(() => {}); 38 44 }, []); 39 45 46 + useSignalEffect(() => { 47 + if (!dirtyRef) return; 48 + dirtyRef.value = 49 + title.value.trim() !== "" || 50 + description.value.trim() !== "" || 51 + selectedLabelIds.value.length > 0; 52 + }); 53 + 40 54 const handleKeyDown = (e: KeyboardEvent) => { 41 55 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 42 56 e.preventDefault(); ··· 150 164 const { activeTab, statuses } = useActiveTab(); 151 165 const { query } = useLocation(); 152 166 const showForm = useSignal(query?.new === "1"); 167 + const formDirty = useSignal(false); 153 168 const { sortBy, sortOrder } = useSortParams(); 154 169 155 170 // Sync with query param changes (e.g. clicking "New" on the dashboard after arriving here). ··· 249 264 </nav> 250 265 251 266 {activeTab === "requests" && ( 252 - <Modal open={showForm} onClose={() => (showForm.value = false)} title="New request"> 253 - <SubmitForm onCreated={onCreated} /> 267 + <Modal 268 + open={showForm} 269 + onClose={() => (showForm.value = false)} 270 + title="New request" 271 + isDirty={() => formDirty.value} 272 + > 273 + <SubmitForm onCreated={onCreated} dirtyRef={formDirty} /> 254 274 </Modal> 255 275 )} 256 276 </div>
+11 -1
packages/kanban/src/ui/components/task-form.tsx
··· 1 - import { useSignal } from "@preact/signals"; 1 + import { useSignal, useSignalEffect, type Signal } from "@preact/signals"; 2 2 import * as ui from "@exosphere/client/ui.css"; 3 3 import { LabelPicker, type LabelOption } from "@exosphere/client/components/label-picker"; 4 4 import { getLabels } from "@exosphere/client/api/labels"; ··· 9 9 columns, 10 10 onCreated, 11 11 initialStatus, 12 + dirtyRef, 12 13 }: { 13 14 columns: KanbanColumnDef[]; 14 15 onCreated: () => void; 15 16 initialStatus?: string; 17 + dirtyRef?: Signal<boolean>; 16 18 }) { 17 19 const title = useSignal(""); 18 20 const description = useSignal(""); ··· 29 31 }) 30 32 .catch(() => {}); 31 33 }, []); 34 + 35 + useSignalEffect(() => { 36 + if (!dirtyRef) return; 37 + dirtyRef.value = 38 + title.value.trim() !== "" || 39 + description.value.trim() !== "" || 40 + selectedLabelIds.value.length > 0; 41 + }); 32 42 33 43 const handleKeyDown = (e: KeyboardEvent) => { 34 44 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+8 -1
packages/kanban/src/ui/pages/board.tsx
··· 20 20 const { query } = useLocation(); 21 21 const showForm = useSignal(query?.new === "1"); 22 22 const formInitialStatus = useSignal<string | undefined>(undefined); 23 + const formDirty = useSignal(false); 23 24 24 25 // Sync with query param changes (e.g. clicking "+ New" on the dashboard after arriving here). 25 26 useEffect(() => { ··· 97 98 </div> 98 99 </div> 99 100 100 - <Modal open={showForm} onClose={() => (showForm.value = false)} title="New task"> 101 + <Modal 102 + open={showForm} 103 + onClose={() => (showForm.value = false)} 104 + title="New task" 105 + isDirty={() => formDirty.value} 106 + > 101 107 <TaskForm 102 108 columns={columnDefs.value} 103 109 onCreated={onCreated} 104 110 initialStatus={formInitialStatus.value} 111 + dirtyRef={formDirty} 105 112 /> 106 113 </Modal> 107 114