Skip to main content

max / goingson

19.0 KB · 518 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 const showToast = (...args) => GoingsOn.modal.showToast(...args);
15 const showUndoToast = (...args) => GoingsOn.modal.showUndoToast(...args);
16 const bulkActionWithUndo = (...args) => GoingsOn.modal.bulkActionWithUndo(...args);
17 const executeUndo = (...args) => GoingsOn.modal.executeUndo(...args);
18 const cancelUndo = (...args) => GoingsOn.modal.cancelUndo(...args);
19 const showConfirmDialog = (...args) => GoingsOn.modal.showConfirmDialog(...args);
20 const showPromptDialog = (...args) => GoingsOn.modal.showPromptDialog(...args);
21 const confirmDelete = (...args) => GoingsOn.modal.confirmDelete(...args);
22 const apiCall = (...args) => GoingsOn.modal.apiCall(...args);
23 const setButtonLoading = (...args) => GoingsOn.modal.setButtonLoading(...args);
24
25 // ============ Shared View Helpers ============
26
27 /**
28 * Render an empty state message with an optional action button.
29 * @param {string} message - The empty state message text
30 * @param {string} [buttonLabel] - Optional button label
31 * @param {string} [onClickFn] - Optional onclick handler (as a string, e.g., "GoingsOn.tasks.openNew()")
32 * @returns {string} - HTML string for the empty state
33 */
34 function renderEmptyState(message, buttonLabel, onClickFn) {
35 let html = `<div class="empty-state"><p class="empty-state-text">${GoingsOn.utils.escapeHtml(message)}</p>`;
36 if (buttonLabel && onClickFn) {
37 html += `<button class="btn btn-primary empty-state-action" onclick="${GoingsOn.utils.escapeAttr(onClickFn)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
38 }
39 html += `</div>`;
40 return html;
41 }
42
43 /**
44 * Render a single form field as an HTML string. The canonical primitive for forms.
45 * @param {Object} field - Field definition
46 * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox'
47 * @param {string} field.name - Form input name
48 * @param {string} [field.label] - Field label
49 * @param {string} [field.id] - Input id (defaults to field.name)
50 * @param {*} [field.value] - Current value
51 * @param {string} [field.placeholder]
52 * @param {boolean} [field.required]
53 * @param {Array<{value, label, selected?}>} [field.options] - For select
54 * @param {string} [field.hint] - Help text under input (HTML-escaped)
55 * @param {string} [field.hintExtraHtml] - Raw HTML appended after hint (NOT escaped — caller must sanitize)
56 * @param {string} [field.error] - Error text (renders has-error variant)
57 * @param {boolean} [field.preview] - Whether to render a preview slot under the input
58 * @returns {string} - HTML string for the field group
59 */
60 function renderFormField(field) {
61 const utils = GoingsOn.utils;
62 const esc = utils.escapeHtml;
63 const escAttr = utils.escapeAttr;
64 const kind = field.kind || field.type || 'text';
65 const inputId = field.id || field.name;
66 const value = field.value ?? '';
67 const required = field.required ? 'required' : '';
68 const placeholder = field.placeholder ? `placeholder="${escAttr(field.placeholder)}"` : '';
69 const extraAttrs = field.attrs
70 ? Object.entries(field.attrs).map(([k, v]) => `${k}="${escAttr(String(v))}"`).join(' ')
71 : '';
72
73 if (kind === 'hidden') {
74 return `<input type="hidden" name="${field.name}" value="${escAttr(value)}">`;
75 }
76
77 let inputHtml = '';
78 let isCheckbox = false;
79
80 switch (kind) {
81 case 'textarea':
82 inputHtml = `<textarea class="form-textarea" id="${inputId}" name="${field.name}" ${required} ${placeholder} ${extraAttrs}>${esc(value)}</textarea>`;
83 break;
84 case 'select': {
85 const optionsHtml = (field.options || []).map(opt => {
86 const selected = (opt.selected || opt.value === value) ? 'selected' : '';
87 return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
88 }).join('');
89 inputHtml = `<select class="form-select" id="${inputId}" name="${field.name}" ${required} ${extraAttrs}>${optionsHtml}</select>`;
90 break;
91 }
92 case 'checkbox':
93 isCheckbox = true;
94 inputHtml = `<label class="form-checkbox-label"><input type="checkbox" id="${inputId}" name="${field.name}" ${value ? 'checked' : ''} ${extraAttrs}><span>${esc(field.label || '')}</span></label>`;
95 break;
96 default:
97 inputHtml = `<input type="${kind}" class="form-input" id="${inputId}" name="${field.name}" ${required} ${placeholder} value="${escAttr(value)}" ${extraAttrs}>`;
98 }
99
100 const hintText = field.hint ? `<div class="form-hint">${esc(field.hint)}</div>` : '';
101 const hintExtra = field.hintExtraHtml || '';
102 const hintHtml = hintText + hintExtra;
103 const previewHtml = field.preview ? `<div id="${inputId}-preview" class="form-hint form-hint--preview"></div>` : '';
104 const errorHtml = field.error ? `<div class="form-error visible">${esc(field.error)}</div>` : '';
105 const errorClass = field.error ? ' has-error' : '';
106
107 if (isCheckbox) {
108 return `<div class="form-group${errorClass}">${inputHtml}${hintHtml}${errorHtml}</div>`;
109 }
110 const labelHtml = field.label ? `<label class="form-label" for="${inputId}">${esc(field.label)}</label>` : '';
111 return `<div class="form-group${errorClass}">${labelHtml}${inputHtml}${hintHtml}${previewHtml}${errorHtml}</div>`;
112 }
113
114 // ============ Context Menu ============
115
116 let contextMenuElement = null;
117 let contextMenuSelectedIndex = -1;
118
119 /**
120 * Show a context menu at the specified position
121 * @param {number} x - X position (clientX)
122 * @param {number} y - Y position (clientY)
123 * @param {Array} items - Menu items [{icon, label, shortcut?, action, danger?}, 'separator', ...]
124 */
125 function showContextMenu(x, y, items) {
126 const menu = document.getElementById('context-menu');
127 if (!menu) return;
128
129 contextMenuElement = menu;
130 contextMenuSelectedIndex = -1;
131
132 // Build menu HTML
133 const html = items.map((item, index) => {
134 if (item === 'separator') {
135 return '<div class="context-menu-separator"></div>';
136 }
137 if (item.type === 'header') {
138 return `<div class="context-menu-header">${GoingsOn.utils.escapeHtml(item.label)}</div>`;
139 }
140 const dangerClass = item.danger ? ' context-menu-item--danger' : '';
141 const shortcutHtml = item.shortcut
142 ? `<span class="context-menu-item-shortcut">${GoingsOn.utils.escapeHtml(item.shortcut)}</span>`
143 : '';
144 const subtitleHtml = item.subtitle
145 ? `<span class="context-menu-item-subtitle">${GoingsOn.utils.escapeHtml(item.subtitle)}</span>`
146 : '';
147 return `
148 <button class="context-menu-item${dangerClass}"
149 data-index="${index}"
150 role="menuitem"
151 tabindex="-1">
152 <span class="context-menu-item-icon">${item.icon || ''}</span>
153 <span class="context-menu-item-label">${GoingsOn.utils.escapeHtml(item.label)}${subtitleHtml}</span>
154 ${shortcutHtml}
155 </button>
156 `;
157 }).join('');
158
159 menu.innerHTML = html;
160
161 // Attach click handlers
162 menu.querySelectorAll('.context-menu-item').forEach((el, i) => {
163 const itemIndex = parseInt(el.dataset.index, 10);
164 const item = items[itemIndex];
165 if (item && item !== 'separator' && item.action) {
166 el.addEventListener('click', () => {
167 hideContextMenu();
168 item.action();
169 });
170 }
171 });
172
173 // Position menu (ensure it stays in viewport)
174 menu.style.left = '0';
175 menu.style.top = '0';
176 menu.classList.add('visible');
177 menu.setAttribute('aria-hidden', 'false');
178
179 const rect = menu.getBoundingClientRect();
180 const viewportWidth = window.innerWidth;
181 const viewportHeight = window.innerHeight;
182
183 let finalX = x;
184 let finalY = y;
185
186 // Adjust if menu would overflow right edge
187 if (x + rect.width > viewportWidth - 10) {
188 finalX = viewportWidth - rect.width - 10;
189 }
190
191 // Adjust if menu would overflow bottom edge
192 if (y + rect.height > viewportHeight - 10) {
193 finalY = viewportHeight - rect.height - 10;
194 }
195
196 menu.style.left = `${finalX}px`;
197 menu.style.top = `${finalY}px`;
198
199 // Focus first item
200 const firstItem = menu.querySelector('.context-menu-item');
201 if (firstItem) {
202 firstItem.focus();
203 contextMenuSelectedIndex = 0;
204 }
205 }
206
207 /**
208 * Hide the context menu
209 */
210 function hideContextMenu() {
211 const menu = document.getElementById('context-menu');
212 if (menu) {
213 menu.classList.remove('visible');
214 menu.setAttribute('aria-hidden', 'true');
215 menu.innerHTML = '';
216 }
217 contextMenuElement = null;
218 contextMenuSelectedIndex = -1;
219 }
220
221 /**
222 * Check if context menu is visible
223 * @returns {boolean}
224 */
225 function isContextMenuVisible() {
226 const menu = document.getElementById('context-menu');
227 return menu && menu.classList.contains('visible');
228 }
229
230 // Close context menu on click outside
231 document.addEventListener('click', (e) => {
232 if (isContextMenuVisible()) {
233 const menu = document.getElementById('context-menu');
234 if (!menu.contains(e.target)) {
235 hideContextMenu();
236 }
237 }
238 });
239
240 // Close context menu on Escape, handle arrow keys
241 document.addEventListener('keydown', (e) => {
242 if (!isContextMenuVisible()) return;
243
244 const menu = document.getElementById('context-menu');
245 const items = menu.querySelectorAll('.context-menu-item');
246
247 switch (e.key) {
248 case 'Escape':
249 e.preventDefault();
250 hideContextMenu();
251 break;
252 case 'ArrowDown':
253 e.preventDefault();
254 contextMenuSelectedIndex = (contextMenuSelectedIndex + 1) % items.length;
255 items[contextMenuSelectedIndex]?.focus();
256 break;
257 case 'ArrowUp':
258 e.preventDefault();
259 contextMenuSelectedIndex = contextMenuSelectedIndex <= 0
260 ? items.length - 1
261 : contextMenuSelectedIndex - 1;
262 items[contextMenuSelectedIndex]?.focus();
263 break;
264 case 'Enter':
265 case ' ':
266 e.preventDefault();
267 if (contextMenuSelectedIndex >= 0) {
268 items[contextMenuSelectedIndex]?.click();
269 }
270 break;
271 }
272 });
273
274 // Close context menu on scroll
275 document.addEventListener('scroll', () => {
276 if (isContextMenuVisible()) {
277 hideContextMenu();
278 }
279 }, true);
280
281 // ============ Context Menu Builders ============
282
283 /**
284 * Get context menu items for a task
285 * @param {string} taskId - Task ID
286 * @param {object} task - Task object (optional, for conditional items)
287 * @returns {Array} - Menu items
288 */
289 function getTaskContextMenuItems(taskId, task = null) {
290 const items = [
291 { label: 'Edit Task', shortcut: 'e', action: () => GoingsOn.tasks.openEdit(taskId) },
292 { label: 'Start Task', subtitle: 'Mark as in-progress', action: () => GoingsOn.tasks.start(taskId) },
293 { label: 'Complete Task', shortcut: 'c', action: () => GoingsOn.tasks.complete(taskId) },
294 'separator',
295 { label: 'Manage Subtasks', action: () => GoingsOn.tasks.openSubtasks(taskId) },
296 { label: 'Add Note', action: () => GoingsOn.tasks.addAnnotation(taskId) },
297 { label: 'Set Milestone...', action: () => GoingsOn.tasks.openSetMilestone(taskId) },
298 'separator',
299 { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('task', taskId) },
300 { type: 'header', label: 'Time' },
301 { label: 'Schedule Time', subtitle: 'Block time on day planner', action: () => GoingsOn.dayPlan.openScheduleTaskModal(taskId) },
302 { label: 'Track Time', subtitle: 'Start live timer', action: () => GoingsOn.timeTracking.startTimer(taskId) },
303 { label: 'Focus Mode', subtitle: 'Pomodoro-style timer', action: () => GoingsOn.focusTimer.start(taskId) },
304 'separator',
305 { label: 'Delete Task', danger: true, action: () => GoingsOn.tasks.delete(taskId) },
306 ];
307 return items;
308 }
309
310 /**
311 * Get context menu items for an email
312 * @param {string} emailId - Email ID
313 * @param {object} email - Email object (optional, for conditional items)
314 * @returns {Array} - Menu items
315 */
316 function getEmailContextMenuItems(emailId, email = null) {
317 const isArchived = email?.is_archived;
318 const isSnoozed = email?.isSnoozed;
319 const isRead = email?.is_read;
320
321 const items = [
322 { label: 'Open Email', action: () => GoingsOn.emails.open(emailId) },
323 'separator',
324 isRead
325 ? { label: 'Mark Unread', action: () => GoingsOn.emails.markUnread(emailId) }
326 : { label: 'Mark Read', action: () => GoingsOn.emails.markRead(emailId) },
327 isArchived
328 ? { label: 'Unarchive', shortcut: 'a', action: () => GoingsOn.emails.unarchive(emailId) }
329 : { label: 'Archive', shortcut: 'a', action: () => GoingsOn.emails.archive(emailId) },
330 'separator',
331 { label: 'Create Task', shortcut: 't', action: () => GoingsOn.emails.createTaskFromEmail(emailId) },
332 { label: 'Create Event', shortcut: 'e', action: () => GoingsOn.emails.createEventFromEmail(emailId) },
333 'separator',
334 isSnoozed
335 ? { label: 'Unsnooze', action: () => GoingsOn.snooze.unsnooze('email', emailId) }
336 : { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('email', emailId) },
337 'separator',
338 { label: 'Delete', danger: true, action: () => GoingsOn.emails.delete(emailId) },
339 ];
340 return items;
341 }
342
343 /**
344 * Get context menu items for an event
345 * @param {string} eventId - Event ID
346 * @returns {Array} - Menu items
347 */
348 function getEventContextMenuItems(eventId) {
349 return [
350 { label: 'Open Event', action: () => GoingsOn.events.open(eventId) },
351 { label: 'Edit Event', action: () => GoingsOn.events.openEdit(eventId) },
352 'separator',
353 { label: 'Delete Event', danger: true, action: () => GoingsOn.events.delete(eventId) },
354 ];
355 }
356
357 /**
358 * Get context menu items for a project
359 * @param {string} projectId - Project ID
360 * @returns {Array} - Menu items
361 */
362 function getProjectContextMenuItems(projectId) {
363 return [
364 { label: 'Open Project', action: () => GoingsOn.projects.open(projectId) },
365 { label: 'Edit Project', action: () => GoingsOn.projects.openEdit(projectId) },
366 'separator',
367 { label: 'Add Task', action: () => GoingsOn.tasks.openNewForProject(projectId) },
368 { label: 'Add Event', action: () => GoingsOn.events.openNewForProject(projectId) },
369 'separator',
370 { label: 'Delete Project', danger: true, action: () => GoingsOn.projects.delete(projectId) },
371 ];
372 }
373
374 // ============ Action Bottom Sheet (mobile context menus) ============
375
376 /**
377 * Show an action sheet (mobile alternative to context menus).
378 * @param {Array} items - Same format as showContextMenu items
379 */
380 // Remember which element triggered the sheet so focus can be restored on close.
381 let actionSheetReturnFocus = null;
382 let actionSheetEscHandler = null;
383
384 function showActionSheet(items) {
385 const sheet = document.getElementById('action-sheet');
386 const content = document.getElementById('action-sheet-content');
387 if (!sheet || !content) return;
388
389 const html = items
390 .filter(item => item !== 'separator')
391 .map(item => {
392 const dangerClass = item.danger ? ' danger' : '';
393 const icon = item.icon ? `<span>${item.icon}</span>` : '';
394 return `<button class="${dangerClass}" data-action="true">${icon}${GoingsOn.utils.escapeHtml(item.label)}</button>`;
395 })
396 .join('');
397
398 content.innerHTML = html;
399
400 // Attach click handlers
401 content.querySelectorAll('button[data-action]').forEach((btn, i) => {
402 const actionItems = items.filter(it => it !== 'separator');
403 const item = actionItems[i];
404 if (item?.action) {
405 btn.addEventListener('click', () => {
406 hideActionSheet();
407 item.action();
408 });
409 }
410 });
411
412 sheet.classList.remove('hidden');
413
414 // Remember focus + move it into the sheet for screen-reader/keyboard users.
415 actionSheetReturnFocus = document.activeElement;
416 const firstButton = content.querySelector('button');
417 if (firstButton) firstButton.focus();
418
419 // Close on Escape (matches modal convention).
420 actionSheetEscHandler = (e) => {
421 if (e.key === 'Escape') hideActionSheet();
422 };
423 document.addEventListener('keydown', actionSheetEscHandler);
424
425 // Close on backdrop tap
426 const backdrop = sheet.querySelector('.action-sheet-backdrop');
427 function onBackdropClick() {
428 hideActionSheet();
429 backdrop.removeEventListener('click', onBackdropClick);
430 }
431 backdrop.addEventListener('click', onBackdropClick);
432
433 // Swipe-down-to-dismiss on the sheet container
434 if (GoingsOn.touch?.isTouchDevice) {
435 const container = sheet.querySelector('.action-sheet-container');
436 GoingsOn.touch.addDragToDismiss(container, hideActionSheet);
437 }
438 }
439
440 /**
441 * Hide the action sheet.
442 */
443 function hideActionSheet() {
444 const sheet = document.getElementById('action-sheet');
445 if (sheet) sheet.classList.add('hidden');
446 if (actionSheetEscHandler) {
447 document.removeEventListener('keydown', actionSheetEscHandler);
448 actionSheetEscHandler = null;
449 }
450 if (actionSheetReturnFocus && typeof actionSheetReturnFocus.focus === 'function') {
451 actionSheetReturnFocus.focus();
452 }
453 actionSheetReturnFocus = null;
454 }
455
456 /**
457 * Smart context menu: uses action sheet on touch devices, regular context menu on desktop.
458 * @param {number} x - X position
459 * @param {number} y - Y position
460 * @param {Array} items - Menu items
461 */
462 const originalShowContextMenu = showContextMenu;
463
464 function showContextMenuSmart(x, y, items) {
465 if (GoingsOn.touch?.isTouchDevice) {
466 showActionSheet(items);
467 } else {
468 originalShowContextMenu(x, y, items);
469 }
470 }
471
472 // ============ Populate GoingsOn.ui Namespace ============
473
474 GoingsOn.ui = {
475 // Modal (delegated to components-modal.js)
476 openModal,
477 closeModal,
478
479 // Toast notifications
480 showToast,
481 showUndoToast,
482 bulkActionWithUndo,
483 executeUndo,
484 cancelUndo,
485
486 // Confirm / prompt dialogs
487 showConfirmDialog,
488 showPromptDialog,
489 confirmDelete,
490
491 // Button state
492 setButtonLoading,
493
494 // Context menu (smart: action sheet on touch, regular on desktop)
495 showContextMenu: showContextMenuSmart,
496 hideContextMenu,
497 isContextMenuVisible,
498
499 // Action sheet (mobile)
500 showActionSheet,
501 hideActionSheet,
502
503 // Context menu builders
504 getTaskContextMenuItems,
505 getEmailContextMenuItems,
506 getEventContextMenuItems,
507 getProjectContextMenuItems,
508
509 // View helpers
510 renderEmptyState,
511 renderFormField,
512
513 // API wrapper
514 apiCall,
515 };
516
517 })();
518