Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

feat: promise-based modal dialogs + kill dead context-menu handlers (#109) (#395)

scott 05bd0f39 a7b376e5

+613 -337
+9
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Added 11 + - Shared modal dialog helper (`src/lib/modal-dialog.ts`): promise-based `modalPrompt()` and `modalConfirm()` replace blocking `window.prompt()`/`window.confirm()`. Native builtins freeze the main thread, stalling Yjs awareness, streaming AI chats, and scheduled timers — the new modals keep the event loop running, respect theme/dark-mode, and support a `destructive` variant for dangerous actions (18 tests). (#109) 12 + 13 + ### Changed 14 + - Replaced 12 blocking `prompt()`/`confirm()` call sites with the new modal helpers: trash permanent-delete and empty-trash, version-panel name-and-restore, calendar event delete, sheets sheet-delete, landing folder delete, landing identity (Tailscale signed-in, change display name), docs toolbar (insert link, insert image, add comment), and the Forge Note button (#681). 15 + 16 + ### Removed 17 + - 9 orphaned context-menu builder helpers (`buildDocsTextItems`, `buildDocsLinkItems`, `buildDocsImageItems`, `buildDocsTableItems`, `buildSheetsCellItems`, `buildSheetsColumnHeaderItems`, `buildSheetsRowHeaderItems`, `buildSheetsContextItems`, plus the `SheetsContextTarget` type): returned menu arrays with 45 no-op `() => {}` handlers and were only referenced by tests — never wired into a real editor. (#109) 18 + 10 19 ## [0.49.0] — 2026-04-16 11 20 12 21 ### Added
+9 -2
src/calendar/main.ts
··· 1461 1461 syncRemindersWithServer().catch(() => {}); 1462 1462 } 1463 1463 1464 - function deleteEvent(): void { 1464 + async function deleteEvent(): Promise<void> { 1465 1465 if (!editingEventId) return; 1466 - if (!confirm('Delete this event?')) return; 1466 + const { modalConfirm } = await import('../lib/modal-dialog.js'); 1467 + const ok = await modalConfirm({ 1468 + title: 'Delete event?', 1469 + message: 'This event will be removed from the calendar.', 1470 + okLabel: 'Delete', 1471 + destructive: true, 1472 + }); 1473 + if (!ok) return; 1467 1474 deleteEventFromYjs(editingEventId); 1468 1475 closeModal(); 1469 1476 }
+117
src/css/app.css
··· 9097 9097 } 9098 9098 .wireframe-item:hover { background: var(--color-surface-alt); } 9099 9099 9100 + /* ── Modal dialog (promise-based prompt/confirm) — #109 ─────────── */ 9101 + 9102 + .modal-dialog-backdrop { 9103 + position: fixed; 9104 + inset: 0; 9105 + background: rgba(0, 0, 0, 0.4); 9106 + display: flex; 9107 + align-items: center; 9108 + justify-content: center; 9109 + z-index: var(--z-modal, 10000); 9110 + animation: modal-fade-in 0.12s ease-out; 9111 + } 9112 + 9113 + [data-theme="dark"] .modal-dialog-backdrop { 9114 + background: rgba(0, 0, 0, 0.6); 9115 + } 9116 + 9117 + @keyframes modal-fade-in { 9118 + from { opacity: 0; } 9119 + to { opacity: 1; } 9120 + } 9121 + 9122 + .modal-dialog { 9123 + background: var(--color-surface); 9124 + color: var(--color-text); 9125 + border: 1px solid var(--color-border); 9126 + border-radius: var(--radius-lg); 9127 + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); 9128 + padding: var(--space-lg); 9129 + min-width: 320px; 9130 + max-width: 480px; 9131 + width: calc(100% - var(--space-xl)); 9132 + display: flex; 9133 + flex-direction: column; 9134 + gap: var(--space-md); 9135 + animation: modal-dialog-in 0.15s ease-out; 9136 + } 9137 + 9138 + @keyframes modal-dialog-in { 9139 + from { transform: translateY(-8px); opacity: 0; } 9140 + to { transform: translateY(0); opacity: 1; } 9141 + } 9142 + 9143 + .modal-dialog-title { 9144 + font-size: 1rem; 9145 + font-weight: 600; 9146 + line-height: 1.3; 9147 + } 9148 + 9149 + .modal-dialog-message { 9150 + color: var(--color-text-muted); 9151 + font-size: 0.875rem; 9152 + line-height: 1.4; 9153 + white-space: pre-wrap; 9154 + } 9155 + 9156 + .modal-dialog-input { 9157 + width: 100%; 9158 + padding: var(--space-sm); 9159 + border: 1px solid var(--color-border); 9160 + border-radius: var(--radius-md); 9161 + background: var(--color-bg); 9162 + color: var(--color-text); 9163 + font-size: 0.875rem; 9164 + font-family: inherit; 9165 + } 9166 + 9167 + .modal-dialog-input:focus { 9168 + outline: none; 9169 + border-color: var(--color-accent); 9170 + box-shadow: 0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent); 9171 + } 9172 + 9173 + .modal-dialog-footer { 9174 + display: flex; 9175 + justify-content: flex-end; 9176 + gap: var(--space-sm); 9177 + } 9178 + 9179 + .modal-dialog-ok, 9180 + .modal-dialog-cancel { 9181 + padding: 6px 14px; 9182 + border: 1px solid var(--color-border); 9183 + border-radius: var(--radius-md); 9184 + background: var(--color-bg); 9185 + color: var(--color-text); 9186 + font-size: 0.8125rem; 9187 + font-family: inherit; 9188 + cursor: pointer; 9189 + transition: background var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast); 9190 + } 9191 + 9192 + .modal-dialog-cancel:hover { 9193 + background: var(--color-surface-alt); 9194 + } 9195 + 9196 + .modal-dialog-ok { 9197 + background: var(--color-accent); 9198 + color: white; 9199 + border-color: var(--color-accent); 9200 + } 9201 + 9202 + .modal-dialog-ok:hover { 9203 + opacity: 0.88; 9204 + } 9205 + 9206 + .modal-dialog-ok--destructive { 9207 + background: var(--color-error, #f14d4c); 9208 + border-color: var(--color-error, #f14d4c); 9209 + } 9210 + 9211 + .modal-dialog-ok:focus-visible, 9212 + .modal-dialog-cancel:focus-visible { 9213 + outline: 2px solid var(--color-accent); 9214 + outline-offset: 2px; 9215 + } 9216 + 9100 9217 /* ── Electron traffic-light padding (landing page, supplements rules at line ~1850) ── */ 9101 9218 .is-electron .landing-header .brand { 9102 9219 padding-left: 88px;
+8 -2
src/docs/main.ts
··· 784 784 btn.textContent = '\u2692 Note for Forge'; 785 785 document.body.appendChild(btn); 786 786 787 - btn.addEventListener('click', () => { 788 - const note = prompt('Note for Forge (will be read on next session):'); 787 + btn.addEventListener('click', async () => { 788 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 789 + const note = await modalPrompt({ 790 + title: 'Note for Forge', 791 + message: 'This note will be read on the next Forge session.', 792 + placeholder: 'What should Forge know?', 793 + okLabel: 'Save', 794 + }); 789 795 if (!note) return; 790 796 const now = new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 791 797 editor.chain().focus('end')
+21 -6
src/docs/toolbar-wiring.ts
··· 8 8 import type { Editor } from '@tiptap/core'; 9 9 import { LINE_SPACING_PRESETS } from './extensions/line-spacing.js'; 10 10 import { PARAGRAPH_SPACING_PRESETS } from './extensions/paragraph-spacing.js'; 11 + import { modalPrompt } from '../lib/modal-dialog.js'; 11 12 12 13 // ── Types ─────────────────────────────────────────────────── 13 14 ··· 249 250 }); 250 251 251 252 // Insert link 252 - $('tb-link').addEventListener('click', () => { 253 - const url = prompt('URL:'); 253 + $('tb-link').addEventListener('click', async () => { 254 + const url = await modalPrompt({ 255 + title: 'Insert link', 256 + message: 'Link URL:', 257 + placeholder: 'https://example.com', 258 + okLabel: 'Insert', 259 + }); 254 260 if (url) editor.chain().focus().setLink({ href: url }).run(); 255 261 }); 256 262 257 263 // Insert image 258 - $('tb-image').addEventListener('click', () => { 259 - const url = prompt('Image URL:'); 264 + $('tb-image').addEventListener('click', async () => { 265 + const url = await modalPrompt({ 266 + title: 'Insert image', 267 + message: 'Image URL:', 268 + placeholder: 'https://example.com/image.png', 269 + okLabel: 'Insert', 270 + }); 260 271 if (url) editor.chain().focus().setImage({ src: url }).run(); 261 272 }); 262 273 ··· 364 375 const commentTimeEl = $('comment-time'); 365 376 const commentTextEl = $('comment-text'); 366 377 367 - $('tb-comment').addEventListener('click', () => { 378 + $('tb-comment').addEventListener('click', async () => { 368 379 const { from, to } = editor.state.selection; 369 380 if (from === to) return; 370 - const commentText = prompt('Add a comment:'); 381 + const commentText = await modalPrompt({ 382 + title: 'Add comment', 383 + message: 'Your comment:', 384 + okLabel: 'Comment', 385 + }); 371 386 if (!commentText) return; 372 387 const commentId = crypto.randomUUID(); 373 388 const timestamp = new Date().toISOString();
+9 -2
src/landing-events-folders.ts
··· 10 10 deleteFolder, 11 11 clearFolderAssignments, 12 12 } from './landing-utils.js'; 13 + import { modalConfirm } from './lib/modal-dialog.js'; 13 14 14 15 // ── Folder modal state (module-scoped) ─────────────────────── 15 16 ··· 24 25 // ── Delegated folder-list listener ─────────────────────────── 25 26 26 27 export function attachFolderListListener(deps: EventDeps): void { 27 - deps.folderListEl.addEventListener('click', (e) => { 28 + deps.folderListEl.addEventListener('click', async (e) => { 28 29 const target = e.target as HTMLElement; 29 30 30 31 // Rename folder ··· 50 51 e.stopPropagation(); 51 52 const folder = deps.getFolders().find(f => f.id === deleteBtn.dataset.id); 52 53 if (!folder) return; 53 - if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 54 + const ok = await modalConfirm({ 55 + title: 'Delete folder?', 56 + message: `Delete folder "${folder.name}"? Documents inside will be moved to the root.`, 57 + okLabel: 'Delete', 58 + destructive: true, 59 + }); 60 + if (!ok) return; 54 61 const clearedAssignments = clearFolderAssignments(deps.getFolderAssignments(), folder.id); 55 62 deps.setFolderAssignments(clearedAssignments); 56 63 const updatedFolders = deleteFolder(deps.getFolders(), folder.id);
+15 -3
src/landing-events-identity.ts
··· 5 5 6 6 import type { EventDeps } from './landing-events.js'; 7 7 import { generateRandomUsername, validateUsername } from './landing-utils.js'; 8 + import { modalPrompt } from './lib/modal-dialog.js'; 8 9 9 10 // ── Tailscale identity (module-scoped) ─────────────────────── 10 11 ··· 74 75 if (e.key === 'Enter') deps.usernameConfirm.click(); 75 76 }); 76 77 77 - deps.userBadge.addEventListener('click', () => { 78 + deps.userBadge.addEventListener('click', async () => { 78 79 if (tsIdentity) { 79 - alert(`Signed in as ${tsIdentity.name}\n${tsIdentity.login}`); 80 + const { modalConfirm } = await import('./lib/modal-dialog.js'); 81 + await modalConfirm({ 82 + title: 'Signed in', 83 + message: `Signed in as ${tsIdentity.name} (${tsIdentity.login}).`, 84 + okLabel: 'OK', 85 + cancelLabel: 'Close', 86 + }); 80 87 return; 81 88 } 82 89 const current = localStorage.getItem('tools-username') || ''; 83 - const newName = prompt('Change your display name:', current); 90 + const newName = await modalPrompt({ 91 + title: 'Change display name', 92 + message: 'This name is shown to collaborators on shared documents.', 93 + defaultValue: current, 94 + okLabel: 'Save', 95 + }); 84 96 if (newName !== null) { 85 97 const trimmed = newName.trim(); 86 98 const result = validateUsername(trimmed);
+15 -2
src/landing-events-trash.ts
··· 5 5 6 6 import type { EventDeps } from './landing-events.js'; 7 7 import { showToast } from './landing-toast.js'; 8 + import { modalConfirm } from './lib/modal-dialog.js'; 8 9 9 10 // ── Delegated trash-list listener ──────────────────────────── 10 11 ··· 34 35 if (permBtn) { 35 36 const doc = deps.getTrashedDocs().find(d => d.id === permBtn.dataset.id); 36 37 const name = doc?._decryptedName || 'this document'; 37 - if (!confirm(`Permanently delete "${name}"?`)) return; 38 + const ok = await modalConfirm({ 39 + title: 'Permanently delete?', 40 + message: `Permanently delete "${name}"? You'll have 30 seconds to undo.`, 41 + okLabel: 'Delete', 42 + destructive: true, 43 + }); 44 + if (!ok) return; 38 45 const id = permBtn.dataset.id!; 39 46 await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 40 47 deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); ··· 80 87 const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 81 88 if (emptyBtn) { 82 89 const docs = deps.getTrashedDocs(); 83 - if (!confirm(`Permanently delete all ${docs.length} trashed documents?`)) return; 90 + const ok = await modalConfirm({ 91 + title: 'Empty trash?', 92 + message: `Permanently delete all ${docs.length} trashed documents? You'll have 30 seconds to undo.`, 93 + okLabel: 'Delete all', 94 + destructive: true, 95 + }); 96 + if (!ok) return; 84 97 const deletedIds: string[] = []; 85 98 for (const doc of docs) { 86 99 try {
-131
src/lib/context-menu.ts
··· 35 35 y: number; 36 36 } 37 37 38 - type SheetsContextTarget = 'cell' | 'colHeader' | 'rowHeader'; 39 - 40 38 /** Sentinel value used as a separator between item groups. */ 41 39 export const SEPARATOR: SeparatorItem = Object.freeze({ type: 'separator' as const }); 42 40 ··· 179 177 return { el: menu, show, hide, destroy }; 180 178 } 181 179 182 - // ---- Docs context menu item builders ---- 183 - 184 - /** 185 - * Build menu items for right-clicking on text in Docs. 186 - * Actions are no-ops here; the consumer wires them to the editor. 187 - */ 188 - export function buildDocsTextItems(): MenuItem[] { 189 - return [ 190 - { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 191 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 192 - { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 193 - { label: 'Select All', shortcut: '\u2318A', action: () => {} }, 194 - SEPARATOR, 195 - { label: 'Bold', icon: 'B', shortcut: '\u2318B', action: () => {} }, 196 - { label: 'Italic', icon: 'I', shortcut: '\u2318I', action: () => {} }, 197 - { label: 'Underline', icon: 'U', shortcut: '\u2318U', action: () => {} }, 198 - SEPARATOR, 199 - { label: 'Link', icon: '\uD83D\uDD17', shortcut: '\u2318K', action: () => {} }, 200 - { label: 'Comment', icon: '\uD83D\uDCAC', action: () => {} }, 201 - ]; 202 - } 203 - 204 - /** 205 - * Build menu items for right-clicking on a link in Docs. 206 - */ 207 - export function buildDocsLinkItems(): MenuItem[] { 208 - return [ 209 - { label: 'Open Link', icon: '\u2197', action: () => {} }, 210 - { label: 'Edit Link', icon: '\u270F', action: () => {} }, 211 - { label: 'Remove Link', icon: '\u2715', action: () => {} }, 212 - SEPARATOR, 213 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 214 - ]; 215 - } 216 - 217 - /** 218 - * Build menu items for right-clicking on an image in Docs. 219 - */ 220 - export function buildDocsImageItems(): MenuItem[] { 221 - return [ 222 - { label: 'Image Properties', icon: '\u2699', action: () => {} }, 223 - SEPARATOR, 224 - { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 225 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 226 - ]; 227 - } 228 - 229 - /** 230 - * Build menu items for right-clicking on a table in Docs. 231 - */ 232 - export function buildDocsTableItems(): MenuItem[] { 233 - return [ 234 - { label: 'Insert Row Above', action: () => {} }, 235 - { label: 'Insert Row Below', action: () => {} }, 236 - { label: 'Insert Column Left', action: () => {} }, 237 - { label: 'Insert Column Right', action: () => {} }, 238 - SEPARATOR, 239 - { label: 'Delete Row', action: () => {} }, 240 - { label: 'Delete Column', action: () => {} }, 241 - SEPARATOR, 242 - { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 243 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 244 - { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 245 - ]; 246 - } 247 - 248 - // ---- Sheets context menu item builders ---- 249 - 250 - /** 251 - * Build menu items for right-clicking on a cell in Sheets. 252 - */ 253 - export function buildSheetsCellItems(): MenuItem[] { 254 - return [ 255 - { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 256 - { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 257 - { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 258 - { label: 'Paste Special', icon: '\uD83D\uDCCB', action: () => {} }, 259 - SEPARATOR, 260 - { label: 'Insert Row Above', action: () => {} }, 261 - { label: 'Insert Row Below', action: () => {} }, 262 - { label: 'Insert Column Left', action: () => {} }, 263 - { label: 'Insert Column Right', action: () => {} }, 264 - SEPARATOR, 265 - { label: 'Delete Row', action: () => {} }, 266 - { label: 'Delete Column', action: () => {} }, 267 - SEPARATOR, 268 - { label: 'Cell Format', icon: '\u2699', action: () => {} }, 269 - { label: 'Add Note', icon: '\uD83D\uDCDD', action: () => {} }, 270 - ]; 271 - } 272 - 273 - /** 274 - * Build menu items for right-clicking on a column header in Sheets. 275 - */ 276 - export function buildSheetsColumnHeaderItems(): MenuItem[] { 277 - return [ 278 - { label: 'Sort A \u2192 Z', icon: '\u2191', action: () => {} }, 279 - { label: 'Sort Z \u2192 A', icon: '\u2193', action: () => {} }, 280 - SEPARATOR, 281 - { label: 'Insert Column', action: () => {} }, 282 - { label: 'Delete Column', action: () => {} }, 283 - SEPARATOR, 284 - { label: 'Resize Column', action: () => {} }, 285 - ]; 286 - } 287 - 288 - /** 289 - * Build menu items for right-clicking on a row header in Sheets. 290 - */ 291 - export function buildSheetsRowHeaderItems(): MenuItem[] { 292 - return [ 293 - { label: 'Insert Row', action: () => {} }, 294 - { label: 'Delete Row', action: () => {} }, 295 - SEPARATOR, 296 - { label: 'Resize Row', action: () => {} }, 297 - ]; 298 - } 299 - 300 - /** 301 - * Dispatcher: return the appropriate menu items for a given sheets target. 302 - */ 303 - export function buildSheetsContextItems(target: SheetsContextTarget | string): MenuItem[] { 304 - switch (target) { 305 - case 'cell': return buildSheetsCellItems(); 306 - case 'colHeader': return buildSheetsColumnHeaderItems(); 307 - case 'rowHeader': return buildSheetsRowHeaderItems(); 308 - default: return []; 309 - } 310 - }
+194
src/lib/modal-dialog.ts
··· 1 + /** 2 + * #109 (v0.51.0) — modal dialog helper. 3 + * 4 + * Replaces `window.prompt()` and `window.confirm()` with in-page modals that 5 + * respect the app's theme, are keyboard-accessible, and return promises. The 6 + * native builtins block the main thread — Yjs awareness, streaming AI chats, 7 + * and scheduled timers all stall while they're open — which corrupts live 8 + * collaboration. Promise-based modals keep the event loop running. 9 + */ 10 + 11 + export interface PromptOptions { 12 + title: string; 13 + message: string; 14 + defaultValue?: string; 15 + placeholder?: string; 16 + okLabel?: string; 17 + cancelLabel?: string; 18 + /** If true, render input as password field. */ 19 + password?: boolean; 20 + } 21 + 22 + export interface ConfirmOptions { 23 + title: string; 24 + message: string; 25 + okLabel?: string; 26 + cancelLabel?: string; 27 + /** Render OK button in destructive/danger color and default focus to Cancel. */ 28 + destructive?: boolean; 29 + } 30 + 31 + interface DialogShell { 32 + backdrop: HTMLElement; 33 + dialog: HTMLElement; 34 + ok: HTMLButtonElement; 35 + cancel: HTMLButtonElement; 36 + } 37 + 38 + function buildShell(opts: { 39 + title: string; 40 + message: string; 41 + okLabel: string; 42 + cancelLabel: string; 43 + destructive: boolean; 44 + }): DialogShell { 45 + const backdrop = document.createElement('div'); 46 + backdrop.className = 'modal-dialog-backdrop'; 47 + 48 + const dialog = document.createElement('div'); 49 + dialog.className = 'modal-dialog'; 50 + dialog.setAttribute('role', 'dialog'); 51 + dialog.setAttribute('aria-modal', 'true'); 52 + 53 + const title = document.createElement('div'); 54 + title.className = 'modal-dialog-title'; 55 + title.textContent = opts.title; 56 + dialog.appendChild(title); 57 + 58 + const message = document.createElement('div'); 59 + message.className = 'modal-dialog-message'; 60 + message.textContent = opts.message; 61 + dialog.appendChild(message); 62 + 63 + const footer = document.createElement('div'); 64 + footer.className = 'modal-dialog-footer'; 65 + 66 + const cancel = document.createElement('button'); 67 + cancel.type = 'button'; 68 + cancel.className = 'modal-dialog-cancel'; 69 + cancel.textContent = opts.cancelLabel; 70 + footer.appendChild(cancel); 71 + 72 + const ok = document.createElement('button'); 73 + ok.type = 'button'; 74 + ok.className = 'modal-dialog-ok'; 75 + if (opts.destructive) ok.classList.add('modal-dialog-ok--destructive'); 76 + ok.textContent = opts.okLabel; 77 + footer.appendChild(ok); 78 + 79 + dialog.appendChild(footer); 80 + backdrop.appendChild(dialog); 81 + document.body.appendChild(backdrop); 82 + 83 + return { backdrop, dialog, ok, cancel }; 84 + } 85 + 86 + /** 87 + * Promise-based prompt dialog. Resolves with the entered string, or `null` 88 + * if the user cancels. 89 + */ 90 + export function modalPrompt(opts: PromptOptions): Promise<string | null> { 91 + return new Promise((resolve) => { 92 + const shell = buildShell({ 93 + title: opts.title, 94 + message: opts.message, 95 + okLabel: opts.okLabel ?? 'OK', 96 + cancelLabel: opts.cancelLabel ?? 'Cancel', 97 + destructive: false, 98 + }); 99 + 100 + // Insert input before footer 101 + const input = document.createElement('input'); 102 + input.type = opts.password ? 'password' : 'text'; 103 + input.className = 'modal-dialog-input'; 104 + input.value = opts.defaultValue ?? ''; 105 + if (opts.placeholder) input.placeholder = opts.placeholder; 106 + shell.dialog.insertBefore(input, shell.dialog.querySelector('.modal-dialog-footer')); 107 + 108 + let settled = false; 109 + const cleanup = () => { 110 + if (settled) return; 111 + settled = true; 112 + document.removeEventListener('keydown', onKeydown, true); 113 + if (shell.backdrop.parentNode) shell.backdrop.parentNode.removeChild(shell.backdrop); 114 + }; 115 + 116 + const onOk = () => { 117 + const value = input.value; 118 + cleanup(); 119 + resolve(value); 120 + }; 121 + const onCancel = () => { 122 + cleanup(); 123 + resolve(null); 124 + }; 125 + const onKeydown = (e: KeyboardEvent) => { 126 + if (e.key === 'Escape') { 127 + e.stopPropagation(); 128 + onCancel(); 129 + } else if (e.key === 'Enter' && e.target === input) { 130 + e.preventDefault(); 131 + onOk(); 132 + } 133 + }; 134 + 135 + shell.ok.addEventListener('click', onOk); 136 + shell.cancel.addEventListener('click', onCancel); 137 + shell.backdrop.addEventListener('click', (e) => { 138 + if (e.target === shell.backdrop) onCancel(); 139 + }); 140 + document.addEventListener('keydown', onKeydown, true); 141 + 142 + input.focus(); 143 + input.select(); 144 + }); 145 + } 146 + 147 + /** 148 + * Promise-based confirm dialog. Resolves `true` if the user clicks OK, 149 + * `false` otherwise (cancel / backdrop / Escape). 150 + */ 151 + export function modalConfirm(opts: ConfirmOptions): Promise<boolean> { 152 + return new Promise((resolve) => { 153 + const shell = buildShell({ 154 + title: opts.title, 155 + message: opts.message, 156 + okLabel: opts.okLabel ?? 'OK', 157 + cancelLabel: opts.cancelLabel ?? 'Cancel', 158 + destructive: opts.destructive ?? false, 159 + }); 160 + 161 + let settled = false; 162 + const cleanup = () => { 163 + if (settled) return; 164 + settled = true; 165 + document.removeEventListener('keydown', onKeydown, true); 166 + if (shell.backdrop.parentNode) shell.backdrop.parentNode.removeChild(shell.backdrop); 167 + }; 168 + 169 + const onOk = () => { 170 + cleanup(); 171 + resolve(true); 172 + }; 173 + const onCancel = () => { 174 + cleanup(); 175 + resolve(false); 176 + }; 177 + const onKeydown = (e: KeyboardEvent) => { 178 + if (e.key === 'Escape') { 179 + e.stopPropagation(); 180 + onCancel(); 181 + } 182 + }; 183 + 184 + shell.ok.addEventListener('click', onOk); 185 + shell.cancel.addEventListener('click', onCancel); 186 + shell.backdrop.addEventListener('click', (e) => { 187 + if (e.target === shell.backdrop) onCancel(); 188 + }); 189 + document.addEventListener('keydown', onKeydown, true); 190 + 191 + if (opts.destructive) shell.cancel.focus(); 192 + else shell.ok.focus(); 193 + }); 194 + }
+9 -2
src/sheets/sheet-tabs-ui.ts
··· 234 234 235 235 // ── Confirm and delete sheet ──────────────────────────────── 236 236 237 - export function confirmAndDeleteSheet(deps: SheetTabsDeps, sheetIdx: number): void { 237 + export async function confirmAndDeleteSheet(deps: SheetTabsDeps, sheetIdx: number): Promise<void> { 238 238 const total = countSheets(deps.ySheets); 239 239 if (total <= 1) return; 240 240 ··· 243 243 244 244 const hasData = sheetHasData(sheet); 245 245 if (hasData) { 246 - if (!confirm('This sheet contains data. Are you sure you want to delete it?')) return; 246 + const { modalConfirm } = await import('../lib/modal-dialog.js'); 247 + const ok = await modalConfirm({ 248 + title: 'Delete sheet?', 249 + message: 'This sheet contains data. Deleting it cannot be undone.', 250 + okLabel: 'Delete', 251 + destructive: true, 252 + }); 253 + if (!ok) return; 247 254 } 248 255 249 256 const newActive = deleteSheet(deps.ydoc as any, deps.ySheets, sheetIdx, deps.getActiveSheetIdx());
+15 -2
src/version-panel.ts
··· 272 272 } 273 273 274 274 async function promptNameVersion(versionId: string, btn: HTMLButtonElement): Promise<void> { 275 - const name = prompt('Name this version:'); 275 + const { modalPrompt } = await import('./lib/modal-dialog.js'); 276 + const name = await modalPrompt({ 277 + title: 'Name this version', 278 + message: 'Give this version a short, memorable name.', 279 + placeholder: 'e.g. Before the big rewrite', 280 + okLabel: 'Save', 281 + }); 276 282 if (!name) return; 277 283 278 284 try { ··· 362 368 363 369 async function handleRestore(): Promise<void> { 364 370 if (!selectedVersionId) return; 365 - if (!confirm('Restore this version? Current changes will be replaced.')) return; 371 + const { modalConfirm } = await import('./lib/modal-dialog.js'); 372 + const ok = await modalConfirm({ 373 + title: 'Restore this version?', 374 + message: 'Current changes will be replaced. This cannot be undone.', 375 + okLabel: 'Restore', 376 + destructive: true, 377 + }); 378 + if (!ok) return; 366 379 367 380 try { 368 381 const res = await fetch(`${apiUrl}/api/documents/${docId}/versions/${selectedVersionId}`);
-185
tests/context-menu.test.ts
··· 3 3 import { 4 4 createContextMenu, 5 5 positionMenu, 6 - buildDocsTextItems, 7 - buildDocsLinkItems, 8 - buildDocsImageItems, 9 - buildDocsTableItems, 10 - buildSheetsContextItems, 11 - buildSheetsCellItems, 12 - buildSheetsColumnHeaderItems, 13 - buildSheetsRowHeaderItems, 14 6 SEPARATOR, 15 7 } from '../src/lib/context-menu.js'; 16 8 ··· 173 165 const pos = positionMenu(-50, -50, 200, 300, 1024, 768); 174 166 expect(pos.x).toBeGreaterThanOrEqual(0); 175 167 expect(pos.y).toBeGreaterThanOrEqual(0); 176 - }); 177 - }); 178 - 179 - // ---- Docs menu item builders ---- 180 - 181 - describe('buildDocsTextItems', () => { 182 - it('returns array of items for text context', () => { 183 - const items = buildDocsTextItems(); 184 - expect(Array.isArray(items)).toBe(true); 185 - expect(items.length).toBeGreaterThan(0); 186 - }); 187 - 188 - it('includes Cut, Copy, Paste, Select All', () => { 189 - const items = buildDocsTextItems(); 190 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 191 - expect(labels).toContain('Cut'); 192 - expect(labels).toContain('Copy'); 193 - expect(labels).toContain('Paste'); 194 - expect(labels).toContain('Select All'); 195 - }); 196 - 197 - it('includes Bold, Italic, Underline', () => { 198 - const items = buildDocsTextItems(); 199 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 200 - expect(labels).toContain('Bold'); 201 - expect(labels).toContain('Italic'); 202 - expect(labels).toContain('Underline'); 203 - }); 204 - 205 - it('includes Link and Comment', () => { 206 - const items = buildDocsTextItems(); 207 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 208 - expect(labels).toContain('Link'); 209 - expect(labels).toContain('Comment'); 210 - }); 211 - 212 - it('has separators between groups', () => { 213 - const items = buildDocsTextItems(); 214 - const sepCount = items.filter(i => i === SEPARATOR).length; 215 - expect(sepCount).toBeGreaterThanOrEqual(2); 216 - }); 217 - 218 - it('every non-separator item has label and action', () => { 219 - const items = buildDocsTextItems(); 220 - items.filter(i => i !== SEPARATOR).forEach(item => { 221 - expect(item.label).toBeTruthy(); 222 - expect(typeof item.action).toBe('function'); 223 - }); 224 - }); 225 - }); 226 - 227 - describe('buildDocsLinkItems', () => { 228 - it('returns array with link-specific items', () => { 229 - const items = buildDocsLinkItems(); 230 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 231 - expect(labels).toContain('Open Link'); 232 - expect(labels).toContain('Edit Link'); 233 - expect(labels).toContain('Remove Link'); 234 - }); 235 - }); 236 - 237 - describe('buildDocsImageItems', () => { 238 - it('returns array with image property item', () => { 239 - const items = buildDocsImageItems(); 240 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 241 - expect(labels).toContain('Image Properties'); 242 - }); 243 - }); 244 - 245 - describe('buildDocsTableItems', () => { 246 - it('returns array with table manipulation items', () => { 247 - const items = buildDocsTableItems(); 248 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 249 - expect(labels).toContain('Insert Row Above'); 250 - expect(labels).toContain('Insert Row Below'); 251 - expect(labels).toContain('Insert Column Left'); 252 - expect(labels).toContain('Insert Column Right'); 253 - expect(labels).toContain('Delete Row'); 254 - expect(labels).toContain('Delete Column'); 255 - }); 256 - }); 257 - 258 - // ---- Sheets menu item builders ---- 259 - 260 - describe('buildSheetsCellItems', () => { 261 - it('returns array of items for cell context', () => { 262 - const items = buildSheetsCellItems(); 263 - expect(Array.isArray(items)).toBe(true); 264 - expect(items.length).toBeGreaterThan(0); 265 - }); 266 - 267 - it('includes Cut, Copy, Paste, Paste Special', () => { 268 - const items = buildSheetsCellItems(); 269 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 270 - expect(labels).toContain('Cut'); 271 - expect(labels).toContain('Copy'); 272 - expect(labels).toContain('Paste'); 273 - expect(labels).toContain('Paste Special'); 274 - }); 275 - 276 - it('includes row/column insert options', () => { 277 - const items = buildSheetsCellItems(); 278 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 279 - expect(labels).toContain('Insert Row Above'); 280 - expect(labels).toContain('Insert Row Below'); 281 - expect(labels).toContain('Insert Column Left'); 282 - expect(labels).toContain('Insert Column Right'); 283 - }); 284 - 285 - it('includes delete row/column options', () => { 286 - const items = buildSheetsCellItems(); 287 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 288 - expect(labels).toContain('Delete Row'); 289 - expect(labels).toContain('Delete Column'); 290 - }); 291 - 292 - it('includes Cell Format and Add Note', () => { 293 - const items = buildSheetsCellItems(); 294 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 295 - expect(labels).toContain('Cell Format'); 296 - expect(labels).toContain('Add Note'); 297 - }); 298 - 299 - it('has separators between groups', () => { 300 - const items = buildSheetsCellItems(); 301 - const sepCount = items.filter(i => i === SEPARATOR).length; 302 - expect(sepCount).toBeGreaterThanOrEqual(2); 303 - }); 304 - }); 305 - 306 - describe('buildSheetsColumnHeaderItems', () => { 307 - it('returns array with column-specific items', () => { 308 - const items = buildSheetsColumnHeaderItems(); 309 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 310 - expect(labels).toContain('Sort A → Z'); 311 - expect(labels).toContain('Sort Z → A'); 312 - expect(labels).toContain('Insert Column'); 313 - expect(labels).toContain('Delete Column'); 314 - expect(labels).toContain('Resize Column'); 315 - }); 316 - }); 317 - 318 - describe('buildSheetsRowHeaderItems', () => { 319 - it('returns array with row-specific items', () => { 320 - const items = buildSheetsRowHeaderItems(); 321 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 322 - expect(labels).toContain('Insert Row'); 323 - expect(labels).toContain('Delete Row'); 324 - expect(labels).toContain('Resize Row'); 325 - }); 326 - }); 327 - 328 - // ---- buildSheetsContextItems (dispatcher) ---- 329 - 330 - describe('buildSheetsContextItems', () => { 331 - it('returns cell items for "cell" target', () => { 332 - const items = buildSheetsContextItems('cell'); 333 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 334 - expect(labels).toContain('Cut'); 335 - expect(labels).toContain('Cell Format'); 336 - }); 337 - 338 - it('returns column header items for "colHeader" target', () => { 339 - const items = buildSheetsContextItems('colHeader'); 340 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 341 - expect(labels).toContain('Sort A → Z'); 342 - }); 343 - 344 - it('returns row header items for "rowHeader" target', () => { 345 - const items = buildSheetsContextItems('rowHeader'); 346 - const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 347 - expect(labels).toContain('Insert Row'); 348 - }); 349 - 350 - it('returns empty array for unknown target', () => { 351 - const items = buildSheetsContextItems('unknown'); 352 - expect(items).toEqual([]); 353 168 }); 354 169 }); 355 170
+192
tests/modal-dialog.test.ts
··· 1 + /** 2 + * #109 (v0.51.0) — modal dialog helper 3 + * 4 + * Replaces window.prompt()/window.confirm() with promise-based in-page modals 5 + * that respect the app's theme, are keyboard-accessible, and don't block the 6 + * main thread (which breaks Yjs awareness, streaming, etc.). 7 + */ 8 + 9 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 10 + import { JSDOM } from 'jsdom'; 11 + 12 + // Under test 13 + import { modalPrompt, modalConfirm } from '../src/lib/modal-dialog.js'; 14 + 15 + describe('modal-dialog', () => { 16 + let dom: JSDOM; 17 + 18 + beforeEach(() => { 19 + dom = new JSDOM('<!DOCTYPE html><html><body></body></html>'); 20 + global.window = dom.window as any; 21 + global.document = dom.window.document; 22 + global.HTMLElement = dom.window.HTMLElement; 23 + global.KeyboardEvent = dom.window.KeyboardEvent; 24 + global.MouseEvent = dom.window.MouseEvent; 25 + }); 26 + 27 + afterEach(() => { 28 + vi.restoreAllMocks(); 29 + document.body.innerHTML = ''; 30 + }); 31 + 32 + describe('modalPrompt', () => { 33 + it('renders a dialog with title, message, input, and buttons', async () => { 34 + const p = modalPrompt({ title: 'Rename', message: 'New name:', defaultValue: 'foo' }); 35 + 36 + const dialog = document.querySelector<HTMLElement>('.modal-dialog'); 37 + expect(dialog).toBeTruthy(); 38 + expect(dialog!.querySelector('.modal-dialog-title')!.textContent).toBe('Rename'); 39 + expect(dialog!.querySelector('.modal-dialog-message')!.textContent).toBe('New name:'); 40 + 41 + const input = dialog!.querySelector<HTMLInputElement>('.modal-dialog-input')!; 42 + expect(input.value).toBe('foo'); 43 + 44 + const ok = dialog!.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 45 + const cancel = dialog!.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!; 46 + expect(ok).toBeTruthy(); 47 + expect(cancel).toBeTruthy(); 48 + 49 + // Resolve so the promise doesn't leak 50 + cancel.click(); 51 + await p; 52 + }); 53 + 54 + it('resolves with the input value when OK is clicked', async () => { 55 + const p = modalPrompt({ title: 'Title', message: 'msg', defaultValue: '' }); 56 + const input = document.querySelector<HTMLInputElement>('.modal-dialog-input')!; 57 + input.value = 'hello'; 58 + document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!.click(); 59 + expect(await p).toBe('hello'); 60 + }); 61 + 62 + it('resolves with null when Cancel is clicked', async () => { 63 + const p = modalPrompt({ title: 'T', message: 'm' }); 64 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 65 + expect(await p).toBeNull(); 66 + }); 67 + 68 + it('resolves with null when Escape is pressed', async () => { 69 + const p = modalPrompt({ title: 'T', message: 'm' }); 70 + const ev = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); 71 + document.dispatchEvent(ev); 72 + expect(await p).toBeNull(); 73 + }); 74 + 75 + it('resolves with input value when Enter is pressed in input', async () => { 76 + const p = modalPrompt({ title: 'T', message: 'm', defaultValue: 'abc' }); 77 + const input = document.querySelector<HTMLInputElement>('.modal-dialog-input')!; 78 + input.value = 'xyz'; 79 + const ev = new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); 80 + input.dispatchEvent(ev); 81 + expect(await p).toBe('xyz'); 82 + }); 83 + 84 + it('removes the dialog from the DOM after resolving', async () => { 85 + const p = modalPrompt({ title: 'T', message: 'm' }); 86 + expect(document.querySelector('.modal-dialog')).toBeTruthy(); 87 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 88 + await p; 89 + expect(document.querySelector('.modal-dialog')).toBeNull(); 90 + }); 91 + 92 + it('focuses the input on open', async () => { 93 + const p = modalPrompt({ title: 'T', message: 'm' }); 94 + const input = document.querySelector<HTMLInputElement>('.modal-dialog-input')!; 95 + // jsdom sets activeElement 96 + expect(document.activeElement).toBe(input); 97 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 98 + await p; 99 + }); 100 + 101 + it('supports custom OK/Cancel labels', async () => { 102 + const p = modalPrompt({ 103 + title: 'T', 104 + message: 'm', 105 + okLabel: 'Rename', 106 + cancelLabel: 'Keep', 107 + }); 108 + expect(document.querySelector('.modal-dialog-ok')!.textContent).toBe('Rename'); 109 + expect(document.querySelector('.modal-dialog-cancel')!.textContent).toBe('Keep'); 110 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 111 + await p; 112 + }); 113 + 114 + it('supports an input placeholder', async () => { 115 + const p = modalPrompt({ title: 'T', message: 'm', placeholder: 'e.g. My Doc' }); 116 + const input = document.querySelector<HTMLInputElement>('.modal-dialog-input')!; 117 + expect(input.placeholder).toBe('e.g. My Doc'); 118 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 119 + await p; 120 + }); 121 + 122 + it('backdrop click cancels the prompt', async () => { 123 + const p = modalPrompt({ title: 'T', message: 'm' }); 124 + const backdrop = document.querySelector<HTMLElement>('.modal-dialog-backdrop')!; 125 + backdrop.click(); 126 + expect(await p).toBeNull(); 127 + }); 128 + 129 + it('sets role=dialog and aria-modal=true for accessibility', async () => { 130 + const p = modalPrompt({ title: 'T', message: 'm' }); 131 + const dialog = document.querySelector<HTMLElement>('.modal-dialog')!; 132 + expect(dialog.getAttribute('role')).toBe('dialog'); 133 + expect(dialog.getAttribute('aria-modal')).toBe('true'); 134 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 135 + await p; 136 + }); 137 + }); 138 + 139 + describe('modalConfirm', () => { 140 + it('renders a dialog with title, message, and buttons (no input)', async () => { 141 + const p = modalConfirm({ title: 'Delete?', message: 'Permanent.' }); 142 + const dialog = document.querySelector<HTMLElement>('.modal-dialog')!; 143 + expect(dialog.querySelector('.modal-dialog-title')!.textContent).toBe('Delete?'); 144 + expect(dialog.querySelector('.modal-dialog-message')!.textContent).toBe('Permanent.'); 145 + expect(dialog.querySelector('.modal-dialog-input')).toBeNull(); 146 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 147 + await p; 148 + }); 149 + 150 + it('resolves true when OK is clicked', async () => { 151 + const p = modalConfirm({ title: 't', message: 'm' }); 152 + document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!.click(); 153 + expect(await p).toBe(true); 154 + }); 155 + 156 + it('resolves false when Cancel is clicked', async () => { 157 + const p = modalConfirm({ title: 't', message: 'm' }); 158 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 159 + expect(await p).toBe(false); 160 + }); 161 + 162 + it('resolves false when Escape is pressed', async () => { 163 + const p = modalConfirm({ title: 't', message: 'm' }); 164 + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); 165 + expect(await p).toBe(false); 166 + }); 167 + 168 + it('supports destructive variant styling', async () => { 169 + const p = modalConfirm({ title: 't', message: 'm', destructive: true }); 170 + const ok = document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 171 + expect(ok.classList.contains('modal-dialog-ok--destructive')).toBe(true); 172 + document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!.click(); 173 + await p; 174 + }); 175 + 176 + it('focuses the cancel button by default for destructive prompts', async () => { 177 + const p = modalConfirm({ title: 't', message: 'm', destructive: true }); 178 + const cancel = document.querySelector<HTMLButtonElement>('.modal-dialog-cancel')!; 179 + expect(document.activeElement).toBe(cancel); 180 + cancel.click(); 181 + await p; 182 + }); 183 + 184 + it('focuses the OK button by default for non-destructive prompts', async () => { 185 + const p = modalConfirm({ title: 't', message: 'm' }); 186 + const ok = document.querySelector<HTMLButtonElement>('.modal-dialog-ok')!; 187 + expect(document.activeElement).toBe(ok); 188 + ok.click(); 189 + await p; 190 + }); 191 + }); 192 + });