Skip to main content

max / goingson

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