Skip to main content

max / goingson

23.6 KB · 630 lines History Blame Raw
1 /**
2 * GoingsOn - UI Components
3 * Reusable UI components: Context menu, action sheet, context menu builders.
4 * Modal, toast, undo, confirm, and apiCall are in components-modal.js.
5 */
6
7 (function() {
8 'use strict';
9
10 // ============ Delegated from components-modal.js ============
11
12 const openModal = (...args) => GoingsOn.modal.openModal(...args);
13 const closeModal = (...args) => GoingsOn.modal.closeModal(...args);
14
15 /** Resolve a `GoingsOn` dot-path (e.g. "emails.open") to its function. */
16 function resolvePath(path) {
17 return String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), window.GoingsOn);
18 }
19
20 /** A do-nothing action. Put `data-act="ui.noop"` on a wrapper so delegation's
21 * `closest()` stops there instead of bubbling to an ancestor's action
22 * (replaces the old inline `event.stopPropagation()` guards). */
23 function noop() {}
24
25 /**
26 * Close the active modal, then resolve a `GoingsOn` dot-path and call it with
27 * the given args. Replaces inline `onclick="ui.closeModal(); foo.bar('x')"`
28 * two-statement handlers with a single delegated action.
29 */
30 function closeModalThen(path, ...args) {
31 closeModal();
32 const fn = resolvePath(path);
33 if (typeof fn === 'function') fn(...args);
34 }
35
36 /** Toggle the `show` class on the element's next sibling (dropdown menus). */
37 function toggleMenu(el) {
38 if (el && el.nextElementSibling) el.nextElementSibling.classList.toggle('show');
39 }
40
41 /** Run a `GoingsOn` action, then close the dropdown the trigger sits in
42 * (replaces `foo(); this.parentElement.classList.remove('show')`). */
43 function menuAction(el, path, ...args) {
44 const fn = resolvePath(path);
45 if (typeof fn === 'function') fn(...args);
46 if (el && el.parentElement) el.parentElement.classList.remove('show');
47 }
48
49 /** Toggle an "expanded" section: flips `expanded` on the button and `hidden`
50 * on its next sibling; optionally swaps label text from data attributes. */
51 function toggleExpand(el) {
52 if (!el) return;
53 el.classList.toggle('expanded');
54 if (el.nextElementSibling) el.nextElementSibling.classList.toggle('hidden');
55 const on = el.dataset.textExpanded, off = el.dataset.textCollapsed;
56 if (on && off) el.textContent = el.classList.contains('expanded') ? on : off;
57 }
58
59 /** Set a localStorage key (replaces inline `localStorage.setItem(...)`). */
60 function setLocalStorage(key, value) {
61 try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ }
62 }
63
64 /** Run a `GoingsOn` action only when Enter was pressed (for keydown handlers). */
65 function onEnter(event, path, ...args) {
66 if (event && event.key === 'Enter') {
67 const fn = resolvePath(path);
68 if (typeof fn === 'function') fn(...args);
69 }
70 }
71
72 /** Set a localStorage flag to "1", close the modal, then optionally run an
73 * action. Replaces the welcome-modal `localStorage.setItem(...); closeModal();
74 * foo()` three-statement handlers. */
75 function markSeenThen(key, path, ...args) {
76 setLocalStorage(key, '1');
77 closeModal();
78 if (path) {
79 const fn = resolvePath(path);
80 if (typeof fn === 'function') fn(...args);
81 }
82 }
83 const showToast = (...args) => GoingsOn.modal.showToast(...args);
84 const showUndoToast = (...args) => GoingsOn.modal.showUndoToast(...args);
85 const bulkActionWithUndo = (...args) => GoingsOn.modal.bulkActionWithUndo(...args);
86 const executeUndo = (...args) => GoingsOn.modal.executeUndo(...args);
87 const cancelUndo = (...args) => GoingsOn.modal.cancelUndo(...args);
88 const showConfirmDialog = (...args) => GoingsOn.modal.showConfirmDialog(...args);
89 const showPromptDialog = (...args) => GoingsOn.modal.showPromptDialog(...args);
90 const confirmDelete = (...args) => GoingsOn.modal.confirmDelete(...args);
91 const apiCall = (...args) => GoingsOn.modal.apiCall(...args);
92 const setButtonLoading = (...args) => GoingsOn.modal.setButtonLoading(...args);
93
94 // ============ Shared View Helpers ============
95
96 /**
97 * Monochrome line icons for empty states. Drawn with `currentColor` so they
98 * inherit the empty-state text color and stay theme-aware. No emoji — the
99 * brand mark is words and geometry, never pictographs.
100 * @type {Object<string,string>}
101 */
102 const EMPTY_STATE_ICONS = {
103 projects: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
104 tasks: '<rect x="4" y="4" width="16" height="17" rx="2"/><path d="M9 3h6v3H9z"/><path d="M8 12l2.5 2.5L16 9"/>',
105 events: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18"/><path d="M8 3v4"/><path d="M16 3v4"/>',
106 emails: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7.5l9 6 9-6"/>',
107 contacts: '<circle cx="12" cy="8" r="4"/><path d="M4 20a8 8 0 0 1 16 0"/>',
108 attachments: '<path d="M20 11.5l-8 8a5 5 0 0 1-7-7l8.5-8.5a3 3 0 0 1 4.5 4L9 13a1.5 1.5 0 0 1-2-2l7-7"/>',
109 inbox: '<path d="M3 13l3-8h12l3 8"/><path d="M3 13h5l1.5 3h5L16 13h5v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
110 };
111
112 /**
113 * Render the SVG icon markup for an empty state.
114 * @param {string} key - One of the EMPTY_STATE_ICONS keys
115 * @returns {string} - HTML string, or '' for an unknown key
116 */
117 function emptyStateIcon(key) {
118 const paths = EMPTY_STATE_ICONS[key];
119 if (!paths) return '';
120 return `<div class="empty-state-icon" aria-hidden="true">`
121 + `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" `
122 + `stroke-linecap="round" stroke-linejoin="round">${paths}</svg></div>`;
123 }
124
125 /**
126 * Render an empty state message with an optional icon and action button.
127 * The canonical empty-state primitive — every view should route through this
128 * so empty/onboarding states read as one designed pattern.
129 * @param {string} message - The empty state message text
130 * @param {string} [buttonLabel] - Optional button label
131 * @param {string} [onClickAct] - Optional delegated action dot-path (e.g. "tasks.openNew")
132 * @param {string} [iconKey] - Optional EMPTY_STATE_ICONS key for a leading icon
133 * @returns {string} - HTML string for the empty state
134 */
135 function renderEmptyState(message, buttonLabel, onClickAct, iconKey) {
136 let html = `<div class="empty-state">${emptyStateIcon(iconKey)}<p class="empty-state-text">${GoingsOn.utils.escapeHtml(message)}</p>`;
137 if (buttonLabel && onClickAct) {
138 html += `<button class="btn btn-primary empty-state-action" data-act="${GoingsOn.utils.escapeAttrValue(onClickAct)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
139 }
140 html += `</div>`;
141 return html;
142 }
143
144 /**
145 * Render a single form field as an HTML string. The canonical primitive for forms.
146 * @param {Object} field - Field definition
147 * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox'
148 * @param {string} field.name - Form input name
149 * @param {string} [field.label] - Field label
150 * @param {string} [field.id] - Input id (defaults to field.name)
151 * @param {*} [field.value] - Current value
152 * @param {string} [field.placeholder]
153 * @param {boolean} [field.required]
154 * @param {Array<{value, label, selected?}>} [field.options] - For select
155 * @param {string} [field.hint] - Help text under input (HTML-escaped)
156 * @param {string} [field.hintExtraHtml] - Raw HTML appended after hint (NOT escaped — caller must sanitize)
157 * @param {string} [field.error] - Error text (renders has-error variant)
158 * @param {boolean} [field.preview] - Whether to render a preview slot under the input
159 * @returns {string} - HTML string for the field group
160 */
161 function renderFormField(field) {
162 const utils = GoingsOn.utils;
163 const esc = utils.escapeHtml;
164 const escAttr = utils.escapeAttrValue;
165 const kind = field.kind || field.type || 'text';
166 const inputId = field.id || field.name;
167 const value = field.value ?? '';
168 const required = field.required ? 'required' : '';
169 const placeholder = field.placeholder ? `placeholder="${escAttr(field.placeholder)}"` : '';
170 const extraAttrs = field.attrs
171 ? Object.entries(field.attrs).map(([k, v]) => `${k}="${escAttr(String(v))}"`).join(' ')
172 : '';
173
174 if (kind === 'hidden') {
175 return `<input type="hidden" name="${field.name}" value="${escAttr(value)}">`;
176 }
177
178 let inputHtml = '';
179 let isCheckbox = false;
180
181 switch (kind) {
182 case 'textarea':
183 inputHtml = `<textarea class="form-textarea" id="${inputId}" name="${field.name}" ${required} ${placeholder} ${extraAttrs}>${esc(value)}</textarea>`;
184 break;
185 case 'select': {
186 const optionsHtml = (field.options || []).map(opt => {
187 const selected = (opt.selected || opt.value === value) ? 'selected' : '';
188 return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
189 }).join('');
190 inputHtml = `<select class="form-select" id="${inputId}" name="${field.name}" ${required} ${extraAttrs}>${optionsHtml}</select>`;
191 break;
192 }
193 case 'checkbox':
194 isCheckbox = true;
195 inputHtml = `<label class="form-checkbox-label"><input type="checkbox" id="${inputId}" name="${field.name}" ${value ? 'checked' : ''} ${extraAttrs}><span>${esc(field.label || '')}</span></label>`;
196 break;
197 default:
198 inputHtml = `<input type="${kind}" class="form-input" id="${inputId}" name="${field.name}" ${required} ${placeholder} value="${escAttr(value)}" ${extraAttrs}>`;
199 }
200
201 const hintText = field.hint ? `<div class="form-hint">${esc(field.hint)}</div>` : '';
202 const hintExtra = field.hintExtraHtml || '';
203 const hintHtml = hintText + hintExtra;
204 const previewHtml = field.preview ? `<div id="${inputId}-preview" class="form-hint form-hint--preview"></div>` : '';
205 const errorHtml = field.error ? `<div class="form-error visible">${esc(field.error)}</div>` : '';
206 const errorClass = field.error ? ' has-error' : '';
207
208 if (isCheckbox) {
209 return `<div class="form-group${errorClass}">${inputHtml}${hintHtml}${errorHtml}</div>`;
210 }
211 const labelHtml = field.label ? `<label class="form-label" for="${inputId}">${esc(field.label)}</label>` : '';
212 return `<div class="form-group${errorClass}">${labelHtml}${inputHtml}${hintHtml}${previewHtml}${errorHtml}</div>`;
213 }
214
215 // ============ Context Menu ============
216
217 let contextMenuElement = null;
218 let contextMenuSelectedIndex = -1;
219
220 /**
221 * Show a context menu at the specified position
222 * @param {number} x - X position (clientX)
223 * @param {number} y - Y position (clientY)
224 * @param {Array} items - Menu items [{icon, label, shortcut?, action, danger?}, 'separator', ...]
225 */
226 function showContextMenu(x, y, items) {
227 const menu = document.getElementById('context-menu');
228 if (!menu) return;
229
230 contextMenuElement = menu;
231 contextMenuSelectedIndex = -1;
232
233 // Build menu HTML
234 const html = items.map((item, index) => {
235 if (item === 'separator') {
236 return '<div class="context-menu-separator"></div>';
237 }
238 if (item.type === 'header') {
239 return `<div class="context-menu-header">${GoingsOn.utils.escapeHtml(item.label)}</div>`;
240 }
241 const dangerClass = item.danger ? ' context-menu-item--danger' : '';
242 const shortcutHtml = item.shortcut
243 ? `<span class="context-menu-item-shortcut">${GoingsOn.utils.escapeHtml(item.shortcut)}</span>`
244 : '';
245 const subtitleHtml = item.subtitle
246 ? `<span class="context-menu-item-subtitle">${GoingsOn.utils.escapeHtml(item.subtitle)}</span>`
247 : '';
248 return `
249 <button class="context-menu-item${dangerClass}"
250 data-index="${index}"
251 role="menuitem"
252 tabindex="-1">
253 <span class="context-menu-item-icon">${item.icon || ''}</span>
254 <span class="context-menu-item-label">${GoingsOn.utils.escapeHtml(item.label)}${subtitleHtml}</span>
255 ${shortcutHtml}
256 </button>
257 `;
258 }).join('');
259
260 menu.innerHTML = html;
261
262 // Attach click handlers
263 menu.querySelectorAll('.context-menu-item').forEach((el, i) => {
264 const itemIndex = parseInt(el.dataset.index, 10);
265 const item = items[itemIndex];
266 if (item && item !== 'separator' && item.action) {
267 el.addEventListener('click', () => {
268 hideContextMenu();
269 item.action();
270 });
271 }
272 });
273
274 // Position menu (ensure it stays in viewport)
275 menu.style.left = '0';
276 menu.style.top = '0';
277 menu.classList.add('visible');
278 menu.setAttribute('aria-hidden', 'false');
279
280 const rect = menu.getBoundingClientRect();
281 const viewportWidth = window.innerWidth;
282 const viewportHeight = window.innerHeight;
283
284 let finalX = x;
285 let finalY = y;
286
287 // Adjust if menu would overflow right edge
288 if (x + rect.width > viewportWidth - 10) {
289 finalX = viewportWidth - rect.width - 10;
290 }
291
292 // Adjust if menu would overflow bottom edge
293 if (y + rect.height > viewportHeight - 10) {
294 finalY = viewportHeight - rect.height - 10;
295 }
296
297 menu.style.left = `${finalX}px`;
298 menu.style.top = `${finalY}px`;
299
300 // Focus first item
301 const firstItem = menu.querySelector('.context-menu-item');
302 if (firstItem) {
303 firstItem.focus();
304 contextMenuSelectedIndex = 0;
305 }
306 }
307
308 /**
309 * Hide the context menu
310 */
311 function hideContextMenu() {
312 const menu = document.getElementById('context-menu');
313 if (menu) {
314 menu.classList.remove('visible');
315 menu.setAttribute('aria-hidden', 'true');
316 menu.innerHTML = '';
317 }
318 contextMenuElement = null;
319 contextMenuSelectedIndex = -1;
320 }
321
322 /**
323 * Check if context menu is visible
324 * @returns {boolean}
325 */
326 function isContextMenuVisible() {
327 const menu = document.getElementById('context-menu');
328 return menu && menu.classList.contains('visible');
329 }
330
331 // Close context menu on click outside
332 document.addEventListener('click', (e) => {
333 if (isContextMenuVisible()) {
334 const menu = document.getElementById('context-menu');
335 if (!menu.contains(e.target)) {
336 hideContextMenu();
337 }
338 }
339 });
340
341 // Close context menu on Escape, handle arrow keys
342 document.addEventListener('keydown', (e) => {
343 if (!isContextMenuVisible()) return;
344
345 const menu = document.getElementById('context-menu');
346 const items = menu.querySelectorAll('.context-menu-item');
347
348 switch (e.key) {
349 case 'Escape':
350 e.preventDefault();
351 hideContextMenu();
352 break;
353 case 'ArrowDown':
354 e.preventDefault();
355 contextMenuSelectedIndex = (contextMenuSelectedIndex + 1) % items.length;
356 items[contextMenuSelectedIndex]?.focus();
357 break;
358 case 'ArrowUp':
359 e.preventDefault();
360 contextMenuSelectedIndex = contextMenuSelectedIndex <= 0
361 ? items.length - 1
362 : contextMenuSelectedIndex - 1;
363 items[contextMenuSelectedIndex]?.focus();
364 break;
365 case 'Enter':
366 case ' ':
367 e.preventDefault();
368 if (contextMenuSelectedIndex >= 0) {
369 items[contextMenuSelectedIndex]?.click();
370 }
371 break;
372 }
373 });
374
375 // Close context menu on scroll
376 document.addEventListener('scroll', () => {
377 if (isContextMenuVisible()) {
378 hideContextMenu();
379 }
380 }, true);
381
382 // ============ Context Menu Builders ============
383
384 /**
385 * Get context menu items for a task
386 * @param {string} taskId - Task ID
387 * @param {object} task - Task object (optional, for conditional items)
388 * @returns {Array} - Menu items
389 */
390 function getTaskContextMenuItems(taskId, task = null) {
391 const items = [
392 { label: 'Edit Task', shortcut: 'e', action: () => GoingsOn.tasks.openEdit(taskId) },
393 { label: 'Start Task', subtitle: 'Mark as in-progress', action: () => GoingsOn.tasks.start(taskId) },
394 { label: 'Complete Task', shortcut: 'c', action: () => GoingsOn.tasks.complete(taskId) },
395 'separator',
396 { label: 'Manage Subtasks', action: () => GoingsOn.tasks.openSubtasks(taskId) },
397 { label: 'Add Note', action: () => GoingsOn.tasks.addAnnotation(taskId) },
398 { label: 'Set Milestone...', action: () => GoingsOn.tasks.openSetMilestone(taskId) },
399 'separator',
400 { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('task', taskId) },
401 { type: 'header', label: 'Time' },
402 { label: 'Schedule Time', subtitle: 'Block time on day planner', action: () => GoingsOn.dayPlan.openScheduleTaskModal(taskId) },
403 { label: 'Track Time', subtitle: 'Start live timer', action: () => GoingsOn.timeTracking.startTimer(taskId) },
404 { label: 'Focus Mode', subtitle: 'Pomodoro-style timer', action: () => GoingsOn.focusTimer.start(taskId) },
405 'separator',
406 { label: 'Delete Task', danger: true, action: () => GoingsOn.tasks.delete(taskId) },
407 ];
408 return items;
409 }
410
411 /**
412 * Get context menu items for an email
413 * @param {string} emailId - Email ID
414 * @param {object} email - Email object (optional, for conditional items)
415 * @returns {Array} - Menu items
416 */
417 function getEmailContextMenuItems(emailId, email = null) {
418 const isArchived = email?.is_archived;
419 const isSnoozed = email?.isSnoozed;
420 const isRead = email?.is_read;
421
422 const items = [
423 { label: 'Open Email', action: () => GoingsOn.emails.open(emailId) },
424 'separator',
425 isRead
426 ? { label: 'Mark Unread', action: () => GoingsOn.emails.markUnread(emailId) }
427 : { label: 'Mark Read', action: () => GoingsOn.emails.markRead(emailId) },
428 isArchived
429 ? { label: 'Unarchive', shortcut: 'a', action: () => GoingsOn.emails.unarchive(emailId) }
430 : { label: 'Archive', shortcut: 'a', action: () => GoingsOn.emails.archive(emailId) },
431 'separator',
432 { label: 'Create Task', shortcut: 't', action: () => GoingsOn.emails.createTaskFromEmail(emailId) },
433 { label: 'Create Event', shortcut: 'e', action: () => GoingsOn.emails.createEventFromEmail(emailId) },
434 'separator',
435 isSnoozed
436 ? { label: 'Unsnooze', action: () => GoingsOn.snooze.unsnooze('email', emailId) }
437 : { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('email', emailId) },
438 'separator',
439 { label: 'Delete', danger: true, action: () => GoingsOn.emails.delete(emailId) },
440 ];
441 return items;
442 }
443
444 /**
445 * Get context menu items for an event
446 * @param {string} eventId - Event ID
447 * @returns {Array} - Menu items
448 */
449 function getEventContextMenuItems(eventId) {
450 return [
451 { label: 'Open Event', action: () => GoingsOn.events.open(eventId) },
452 { label: 'Edit Event', action: () => GoingsOn.events.openEdit(eventId) },
453 'separator',
454 { label: 'Delete Event', danger: true, action: () => GoingsOn.events.delete(eventId) },
455 ];
456 }
457
458 /**
459 * Get context menu items for a project
460 * @param {string} projectId - Project ID
461 * @returns {Array} - Menu items
462 */
463 function getProjectContextMenuItems(projectId) {
464 return [
465 { label: 'Open Project', action: () => GoingsOn.projects.open(projectId) },
466 { label: 'Edit Project', action: () => GoingsOn.projects.openEdit(projectId) },
467 'separator',
468 { label: 'Add Task', action: () => GoingsOn.tasks.openNewForProject(projectId) },
469 { label: 'Add Event', action: () => GoingsOn.events.openNewForProject(projectId) },
470 'separator',
471 { label: 'Delete Project', danger: true, action: () => GoingsOn.projects.delete(projectId) },
472 ];
473 }
474
475 // ============ Action Bottom Sheet (mobile context menus) ============
476
477 /**
478 * Show an action sheet (mobile alternative to context menus).
479 * @param {Array} items - Same format as showContextMenu items
480 */
481 // Remember which element triggered the sheet so focus can be restored on close.
482 let actionSheetReturnFocus = null;
483 let actionSheetEscHandler = null;
484
485 function showActionSheet(items) {
486 const sheet = document.getElementById('action-sheet');
487 const content = document.getElementById('action-sheet-content');
488 if (!sheet || !content) return;
489
490 const html = items
491 .filter(item => item !== 'separator')
492 .map(item => {
493 const dangerClass = item.danger ? ' danger' : '';
494 const icon = item.icon ? `<span>${item.icon}</span>` : '';
495 return `<button class="${dangerClass}" data-action="true">${icon}${GoingsOn.utils.escapeHtml(item.label)}</button>`;
496 })
497 .join('');
498
499 content.innerHTML = html;
500
501 // Attach click handlers
502 content.querySelectorAll('button[data-action]').forEach((btn, i) => {
503 const actionItems = items.filter(it => it !== 'separator');
504 const item = actionItems[i];
505 if (item?.action) {
506 btn.addEventListener('click', () => {
507 hideActionSheet();
508 item.action();
509 });
510 }
511 });
512
513 sheet.classList.remove('hidden');
514
515 // Remember focus + move it into the sheet for screen-reader/keyboard users.
516 actionSheetReturnFocus = document.activeElement;
517 const firstButton = content.querySelector('button');
518 if (firstButton) firstButton.focus();
519
520 // Close on Escape (matches modal convention).
521 actionSheetEscHandler = (e) => {
522 if (e.key === 'Escape') hideActionSheet();
523 };
524 document.addEventListener('keydown', actionSheetEscHandler);
525
526 // Close on backdrop tap
527 const backdrop = sheet.querySelector('.action-sheet-backdrop');
528 function onBackdropClick() {
529 hideActionSheet();
530 backdrop.removeEventListener('click', onBackdropClick);
531 }
532 backdrop.addEventListener('click', onBackdropClick);
533
534 // Swipe-down-to-dismiss on the sheet container
535 if (GoingsOn.touch?.isTouchDevice) {
536 const container = sheet.querySelector('.action-sheet-container');
537 GoingsOn.touch.addDragToDismiss(container, hideActionSheet);
538 }
539 }
540
541 /**
542 * Hide the action sheet.
543 */
544 function hideActionSheet() {
545 const sheet = document.getElementById('action-sheet');
546 if (sheet) sheet.classList.add('hidden');
547 if (actionSheetEscHandler) {
548 document.removeEventListener('keydown', actionSheetEscHandler);
549 actionSheetEscHandler = null;
550 }
551 if (actionSheetReturnFocus && typeof actionSheetReturnFocus.focus === 'function') {
552 actionSheetReturnFocus.focus();
553 }
554 actionSheetReturnFocus = null;
555 }
556
557 /**
558 * Smart context menu: uses action sheet on touch devices, regular context menu on desktop.
559 * @param {number} x - X position
560 * @param {number} y - Y position
561 * @param {Array} items - Menu items
562 */
563 const originalShowContextMenu = showContextMenu;
564
565 function showContextMenuSmart(x, y, items) {
566 if (GoingsOn.touch?.isTouchDevice) {
567 showActionSheet(items);
568 } else {
569 originalShowContextMenu(x, y, items);
570 }
571 }
572
573 // ============ Populate GoingsOn.ui Namespace ============
574
575 GoingsOn.ui = {
576 // Modal (delegated to components-modal.js)
577 openModal,
578 closeModal,
579 closeModalThen,
580
581 // Delegated-event helpers (replace inline on* handlers; see dispatch.js)
582 noop,
583 toggleMenu,
584 menuAction,
585 toggleExpand,
586 setLocalStorage,
587 onEnter,
588 markSeenThen,
589
590 // Toast notifications
591 showToast,
592 showUndoToast,
593 bulkActionWithUndo,
594 executeUndo,
595 cancelUndo,
596
597 // Confirm / prompt dialogs
598 showConfirmDialog,
599 showPromptDialog,
600 confirmDelete,
601
602 // Button state
603 setButtonLoading,
604
605 // Context menu (smart: action sheet on touch, regular on desktop)
606 showContextMenu: showContextMenuSmart,
607 hideContextMenu,
608 isContextMenuVisible,
609
610 // Action sheet (mobile)
611 showActionSheet,
612 hideActionSheet,
613
614 // Context menu builders
615 getTaskContextMenuItems,
616 getEmailContextMenuItems,
617 getEventContextMenuItems,
618 getProjectContextMenuItems,
619
620 // View helpers
621 renderEmptyState,
622 emptyStateIcon,
623 renderFormField,
624
625 // API wrapper
626 apiCall,
627 };
628
629 })();
630