/** * GoingsOn - UI Components * Reusable UI components: Context menu, action sheet, context menu builders. * Modal, toast, undo, confirm, and apiCall are in components-modal.js. */ (function() { 'use strict'; // ============ Delegated from components-modal.js ============ const openModal = (...args) => GoingsOn.modal.openModal(...args); const closeModal = (...args) => GoingsOn.modal.closeModal(...args); const showToast = (...args) => GoingsOn.modal.showToast(...args); const showUndoToast = (...args) => GoingsOn.modal.showUndoToast(...args); const bulkActionWithUndo = (...args) => GoingsOn.modal.bulkActionWithUndo(...args); const executeUndo = (...args) => GoingsOn.modal.executeUndo(...args); const cancelUndo = (...args) => GoingsOn.modal.cancelUndo(...args); const showConfirmDialog = (...args) => GoingsOn.modal.showConfirmDialog(...args); const showPromptDialog = (...args) => GoingsOn.modal.showPromptDialog(...args); const confirmDelete = (...args) => GoingsOn.modal.confirmDelete(...args); const apiCall = (...args) => GoingsOn.modal.apiCall(...args); const setButtonLoading = (...args) => GoingsOn.modal.setButtonLoading(...args); // ============ Shared View Helpers ============ /** * Monochrome line icons for empty states. Drawn with `currentColor` so they * inherit the empty-state text color and stay theme-aware. No emoji — the * brand mark is words and geometry, never pictographs. * @type {Object} */ const EMPTY_STATE_ICONS = { projects: '', tasks: '', events: '', emails: '', contacts: '', attachments: '', inbox: '', }; /** * Render the SVG icon markup for an empty state. * @param {string} key - One of the EMPTY_STATE_ICONS keys * @returns {string} - HTML string, or '' for an unknown key */ function emptyStateIcon(key) { const paths = EMPTY_STATE_ICONS[key]; if (!paths) return ''; return ``; } /** * Render an empty state message with an optional icon and action button. * The canonical empty-state primitive — every view should route through this * so empty/onboarding states read as one designed pattern. * @param {string} message - The empty state message text * @param {string} [buttonLabel] - Optional button label * @param {string} [onClickFn] - Optional onclick handler (as a string, e.g., "GoingsOn.tasks.openNew()") * @param {string} [iconKey] - Optional EMPTY_STATE_ICONS key for a leading icon * @returns {string} - HTML string for the empty state */ function renderEmptyState(message, buttonLabel, onClickFn, iconKey) { let html = `
${emptyStateIcon(iconKey)}

${GoingsOn.utils.escapeHtml(message)}

`; if (buttonLabel && onClickFn) { html += ``; } html += `
`; return html; } /** * Render a single form field as an HTML string. The canonical primitive for forms. * @param {Object} field - Field definition * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox' * @param {string} field.name - Form input name * @param {string} [field.label] - Field label * @param {string} [field.id] - Input id (defaults to field.name) * @param {*} [field.value] - Current value * @param {string} [field.placeholder] * @param {boolean} [field.required] * @param {Array<{value, label, selected?}>} [field.options] - For select * @param {string} [field.hint] - Help text under input (HTML-escaped) * @param {string} [field.hintExtraHtml] - Raw HTML appended after hint (NOT escaped — caller must sanitize) * @param {string} [field.error] - Error text (renders has-error variant) * @param {boolean} [field.preview] - Whether to render a preview slot under the input * @returns {string} - HTML string for the field group */ function renderFormField(field) { const utils = GoingsOn.utils; const esc = utils.escapeHtml; const escAttr = utils.escapeAttr; const kind = field.kind || field.type || 'text'; const inputId = field.id || field.name; const value = field.value ?? ''; const required = field.required ? 'required' : ''; const placeholder = field.placeholder ? `placeholder="${escAttr(field.placeholder)}"` : ''; const extraAttrs = field.attrs ? Object.entries(field.attrs).map(([k, v]) => `${k}="${escAttr(String(v))}"`).join(' ') : ''; if (kind === 'hidden') { return ``; } let inputHtml = ''; let isCheckbox = false; switch (kind) { case 'textarea': inputHtml = ``; break; case 'select': { const optionsHtml = (field.options || []).map(opt => { const selected = (opt.selected || opt.value === value) ? 'selected' : ''; return ``; }).join(''); inputHtml = ``; break; } case 'checkbox': isCheckbox = true; inputHtml = ``; break; default: inputHtml = ``; } const hintText = field.hint ? `
${esc(field.hint)}
` : ''; const hintExtra = field.hintExtraHtml || ''; const hintHtml = hintText + hintExtra; const previewHtml = field.preview ? `
` : ''; const errorHtml = field.error ? `
${esc(field.error)}
` : ''; const errorClass = field.error ? ' has-error' : ''; if (isCheckbox) { return `
${inputHtml}${hintHtml}${errorHtml}
`; } const labelHtml = field.label ? `` : ''; return `
${labelHtml}${inputHtml}${hintHtml}${previewHtml}${errorHtml}
`; } // ============ Context Menu ============ let contextMenuElement = null; let contextMenuSelectedIndex = -1; /** * Show a context menu at the specified position * @param {number} x - X position (clientX) * @param {number} y - Y position (clientY) * @param {Array} items - Menu items [{icon, label, shortcut?, action, danger?}, 'separator', ...] */ function showContextMenu(x, y, items) { const menu = document.getElementById('context-menu'); if (!menu) return; contextMenuElement = menu; contextMenuSelectedIndex = -1; // Build menu HTML const html = items.map((item, index) => { if (item === 'separator') { return '
'; } if (item.type === 'header') { return `
${GoingsOn.utils.escapeHtml(item.label)}
`; } const dangerClass = item.danger ? ' context-menu-item--danger' : ''; const shortcutHtml = item.shortcut ? `${GoingsOn.utils.escapeHtml(item.shortcut)}` : ''; const subtitleHtml = item.subtitle ? `${GoingsOn.utils.escapeHtml(item.subtitle)}` : ''; return ` `; }).join(''); menu.innerHTML = html; // Attach click handlers menu.querySelectorAll('.context-menu-item').forEach((el, i) => { const itemIndex = parseInt(el.dataset.index, 10); const item = items[itemIndex]; if (item && item !== 'separator' && item.action) { el.addEventListener('click', () => { hideContextMenu(); item.action(); }); } }); // Position menu (ensure it stays in viewport) menu.style.left = '0'; menu.style.top = '0'; menu.classList.add('visible'); menu.setAttribute('aria-hidden', 'false'); const rect = menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let finalX = x; let finalY = y; // Adjust if menu would overflow right edge if (x + rect.width > viewportWidth - 10) { finalX = viewportWidth - rect.width - 10; } // Adjust if menu would overflow bottom edge if (y + rect.height > viewportHeight - 10) { finalY = viewportHeight - rect.height - 10; } menu.style.left = `${finalX}px`; menu.style.top = `${finalY}px`; // Focus first item const firstItem = menu.querySelector('.context-menu-item'); if (firstItem) { firstItem.focus(); contextMenuSelectedIndex = 0; } } /** * Hide the context menu */ function hideContextMenu() { const menu = document.getElementById('context-menu'); if (menu) { menu.classList.remove('visible'); menu.setAttribute('aria-hidden', 'true'); menu.innerHTML = ''; } contextMenuElement = null; contextMenuSelectedIndex = -1; } /** * Check if context menu is visible * @returns {boolean} */ function isContextMenuVisible() { const menu = document.getElementById('context-menu'); return menu && menu.classList.contains('visible'); } // Close context menu on click outside document.addEventListener('click', (e) => { if (isContextMenuVisible()) { const menu = document.getElementById('context-menu'); if (!menu.contains(e.target)) { hideContextMenu(); } } }); // Close context menu on Escape, handle arrow keys document.addEventListener('keydown', (e) => { if (!isContextMenuVisible()) return; const menu = document.getElementById('context-menu'); const items = menu.querySelectorAll('.context-menu-item'); switch (e.key) { case 'Escape': e.preventDefault(); hideContextMenu(); break; case 'ArrowDown': e.preventDefault(); contextMenuSelectedIndex = (contextMenuSelectedIndex + 1) % items.length; items[contextMenuSelectedIndex]?.focus(); break; case 'ArrowUp': e.preventDefault(); contextMenuSelectedIndex = contextMenuSelectedIndex <= 0 ? items.length - 1 : contextMenuSelectedIndex - 1; items[contextMenuSelectedIndex]?.focus(); break; case 'Enter': case ' ': e.preventDefault(); if (contextMenuSelectedIndex >= 0) { items[contextMenuSelectedIndex]?.click(); } break; } }); // Close context menu on scroll document.addEventListener('scroll', () => { if (isContextMenuVisible()) { hideContextMenu(); } }, true); // ============ Context Menu Builders ============ /** * Get context menu items for a task * @param {string} taskId - Task ID * @param {object} task - Task object (optional, for conditional items) * @returns {Array} - Menu items */ function getTaskContextMenuItems(taskId, task = null) { const items = [ { label: 'Edit Task', shortcut: 'e', action: () => GoingsOn.tasks.openEdit(taskId) }, { label: 'Start Task', subtitle: 'Mark as in-progress', action: () => GoingsOn.tasks.start(taskId) }, { label: 'Complete Task', shortcut: 'c', action: () => GoingsOn.tasks.complete(taskId) }, 'separator', { label: 'Manage Subtasks', action: () => GoingsOn.tasks.openSubtasks(taskId) }, { label: 'Add Note', action: () => GoingsOn.tasks.addAnnotation(taskId) }, { label: 'Set Milestone...', action: () => GoingsOn.tasks.openSetMilestone(taskId) }, 'separator', { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('task', taskId) }, { type: 'header', label: 'Time' }, { label: 'Schedule Time', subtitle: 'Block time on day planner', action: () => GoingsOn.dayPlan.openScheduleTaskModal(taskId) }, { label: 'Track Time', subtitle: 'Start live timer', action: () => GoingsOn.timeTracking.startTimer(taskId) }, { label: 'Focus Mode', subtitle: 'Pomodoro-style timer', action: () => GoingsOn.focusTimer.start(taskId) }, 'separator', { label: 'Delete Task', danger: true, action: () => GoingsOn.tasks.delete(taskId) }, ]; return items; } /** * Get context menu items for an email * @param {string} emailId - Email ID * @param {object} email - Email object (optional, for conditional items) * @returns {Array} - Menu items */ function getEmailContextMenuItems(emailId, email = null) { const isArchived = email?.is_archived; const isSnoozed = email?.isSnoozed; const isRead = email?.is_read; const items = [ { label: 'Open Email', action: () => GoingsOn.emails.open(emailId) }, 'separator', isRead ? { label: 'Mark Unread', action: () => GoingsOn.emails.markUnread(emailId) } : { label: 'Mark Read', action: () => GoingsOn.emails.markRead(emailId) }, isArchived ? { label: 'Unarchive', shortcut: 'a', action: () => GoingsOn.emails.unarchive(emailId) } : { label: 'Archive', shortcut: 'a', action: () => GoingsOn.emails.archive(emailId) }, 'separator', { label: 'Create Task', shortcut: 't', action: () => GoingsOn.emails.createTaskFromEmail(emailId) }, { label: 'Create Event', shortcut: 'e', action: () => GoingsOn.emails.createEventFromEmail(emailId) }, 'separator', isSnoozed ? { label: 'Unsnooze', action: () => GoingsOn.snooze.unsnooze('email', emailId) } : { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('email', emailId) }, 'separator', { label: 'Delete', danger: true, action: () => GoingsOn.emails.delete(emailId) }, ]; return items; } /** * Get context menu items for an event * @param {string} eventId - Event ID * @returns {Array} - Menu items */ function getEventContextMenuItems(eventId) { return [ { label: 'Open Event', action: () => GoingsOn.events.open(eventId) }, { label: 'Edit Event', action: () => GoingsOn.events.openEdit(eventId) }, 'separator', { label: 'Delete Event', danger: true, action: () => GoingsOn.events.delete(eventId) }, ]; } /** * Get context menu items for a project * @param {string} projectId - Project ID * @returns {Array} - Menu items */ function getProjectContextMenuItems(projectId) { return [ { label: 'Open Project', action: () => GoingsOn.projects.open(projectId) }, { label: 'Edit Project', action: () => GoingsOn.projects.openEdit(projectId) }, 'separator', { label: 'Add Task', action: () => GoingsOn.tasks.openNewForProject(projectId) }, { label: 'Add Event', action: () => GoingsOn.events.openNewForProject(projectId) }, 'separator', { label: 'Delete Project', danger: true, action: () => GoingsOn.projects.delete(projectId) }, ]; } // ============ Action Bottom Sheet (mobile context menus) ============ /** * Show an action sheet (mobile alternative to context menus). * @param {Array} items - Same format as showContextMenu items */ // Remember which element triggered the sheet so focus can be restored on close. let actionSheetReturnFocus = null; let actionSheetEscHandler = null; function showActionSheet(items) { const sheet = document.getElementById('action-sheet'); const content = document.getElementById('action-sheet-content'); if (!sheet || !content) return; const html = items .filter(item => item !== 'separator') .map(item => { const dangerClass = item.danger ? ' danger' : ''; const icon = item.icon ? `${item.icon}` : ''; return ``; }) .join(''); content.innerHTML = html; // Attach click handlers content.querySelectorAll('button[data-action]').forEach((btn, i) => { const actionItems = items.filter(it => it !== 'separator'); const item = actionItems[i]; if (item?.action) { btn.addEventListener('click', () => { hideActionSheet(); item.action(); }); } }); sheet.classList.remove('hidden'); // Remember focus + move it into the sheet for screen-reader/keyboard users. actionSheetReturnFocus = document.activeElement; const firstButton = content.querySelector('button'); if (firstButton) firstButton.focus(); // Close on Escape (matches modal convention). actionSheetEscHandler = (e) => { if (e.key === 'Escape') hideActionSheet(); }; document.addEventListener('keydown', actionSheetEscHandler); // Close on backdrop tap const backdrop = sheet.querySelector('.action-sheet-backdrop'); function onBackdropClick() { hideActionSheet(); backdrop.removeEventListener('click', onBackdropClick); } backdrop.addEventListener('click', onBackdropClick); // Swipe-down-to-dismiss on the sheet container if (GoingsOn.touch?.isTouchDevice) { const container = sheet.querySelector('.action-sheet-container'); GoingsOn.touch.addDragToDismiss(container, hideActionSheet); } } /** * Hide the action sheet. */ function hideActionSheet() { const sheet = document.getElementById('action-sheet'); if (sheet) sheet.classList.add('hidden'); if (actionSheetEscHandler) { document.removeEventListener('keydown', actionSheetEscHandler); actionSheetEscHandler = null; } if (actionSheetReturnFocus && typeof actionSheetReturnFocus.focus === 'function') { actionSheetReturnFocus.focus(); } actionSheetReturnFocus = null; } /** * Smart context menu: uses action sheet on touch devices, regular context menu on desktop. * @param {number} x - X position * @param {number} y - Y position * @param {Array} items - Menu items */ const originalShowContextMenu = showContextMenu; function showContextMenuSmart(x, y, items) { if (GoingsOn.touch?.isTouchDevice) { showActionSheet(items); } else { originalShowContextMenu(x, y, items); } } // ============ Populate GoingsOn.ui Namespace ============ GoingsOn.ui = { // Modal (delegated to components-modal.js) openModal, closeModal, // Toast notifications showToast, showUndoToast, bulkActionWithUndo, executeUndo, cancelUndo, // Confirm / prompt dialogs showConfirmDialog, showPromptDialog, confirmDelete, // Button state setButtonLoading, // Context menu (smart: action sheet on touch, regular on desktop) showContextMenu: showContextMenuSmart, hideContextMenu, isContextMenuVisible, // Action sheet (mobile) showActionSheet, hideActionSheet, // Context menu builders getTaskContextMenuItems, getEmailContextMenuItems, getEventContextMenuItems, getProjectContextMenuItems, // View helpers renderEmptyState, emptyStateIcon, renderFormField, // API wrapper apiCall, }; })();