`;
}
// ============ 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,
renderFormField,
// API wrapper
apiCall,
};
})();