diff --git a/resources/js/app.js b/resources/js/app.js
index 31a1958..af5affe 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,172 +1,21 @@
import './bootstrap';
import htmx from 'htmx.org';
+import { handleEventModalAfterSwap, initEventModalGlobals, initEventModalUI } from './modules/event-modal';
const SELECTORS = {
calendarToggle: '.calendar-toggle',
- calendarViewForm: '#calendar-view',
calendarExpandToggle: '[data-calendar-expand]',
colorPicker: '[data-colorpicker]',
colorPickerColor: '[data-colorpicker-color]',
colorPickerHex: '[data-colorpicker-hex]',
colorPickerRandom: '[data-colorpicker-random]',
- calendarPicker: '[data-calendar-picker]',
- calendarPickerInput: '[data-calendar-picker-input]',
- calendarPickerToggle: '[data-calendar-picker-toggle]',
- calendarPickerMenu: '[data-calendar-picker-menu]',
- calendarPickerLabel: '[data-calendar-picker-label]',
- calendarPickerColorDot: '[data-calendar-picker-color]',
- calendarPickerOption: '[data-calendar-picker-option]',
- eventAllDayToggle: '[data-all-day-toggle]',
- eventStartInput: '[data-event-start]',
- eventEndInput: '[data-event-end]',
- eventTitleInput: 'input[name="title"]',
- eventLocationInput: 'input[name="location"]',
- modalDialog: 'dialog',
- modalContent: '#modal',
- modalClassSource: '[data-modal-class]',
- modalAside: '#modal-aside',
- modalAsideList: '[data-modal-aside-list]',
- modalExpand: '[data-modal-expand]',
monthDay: '.calendar.month .day',
monthDayEvent: 'a.event',
monthDayMore: '[data-day-more]',
monthDayMoreWrap: '.more-events',
- monthlyMode: '[data-monthly-mode]',
- monthlyDays: '[data-monthly-days]',
- monthlyWeekday: '[data-monthly-weekday]',
- naturalEventInput: '[data-natural-event-input]',
- recurrenceFrequency: '[data-recurrence-frequency]',
- recurrenceInterval: '[data-recurrence-interval]',
- recurrenceUnit: '[data-recurrence-unit]',
- recurrenceSection: '[data-recurrence-section]',
- tabsRoot: '[data-tabs]',
- tabButton: '[role=\"tab\"]',
- tabPanel: '[role=\"tabpanel\"]',
- attendeesRoot: '[data-attendees]',
- attendeesList: '[data-attendees-list]',
- attendeeTemplate: 'template[data-attendee-template]',
- attendeeLookup: '[data-attendee-lookup]',
- attendeeSuggestions: '#attendee-suggestions',
- attendeeAddManual: '[data-attendee-add-manual]',
- attendeePick: '[data-attendee-pick]',
- attendeeRemove: '[data-attendee-remove]',
- attendeeRow: '[data-attendee-row]',
- attendeeRole: '[data-attendee-role]',
- attendeeName: '[data-attendee-name]',
- attendeeOptional: '[data-attendee-optional]',
- attendeeDisplay: '[data-attendee-display]',
- attendeeVerified: '[data-attendee-verified]',
- attendeeEmail: '[data-attendee-email]',
- attendeeUri: '[data-attendee-uri]',
};
-function syncModalRootClass(modal) {
- if (!modal) return;
-
- const previous = (modal.dataset.appliedClass || '')
- .split(/\s+/)
- .filter(Boolean);
-
- if (previous.length) {
- modal.classList.remove(...previous);
- }
-
- const source = modal.querySelector(SELECTORS.modalClassSource);
- const next = (source?.dataset?.modalClass || '')
- .split(/\s+/)
- .filter(Boolean);
-
- if (next.length) {
- modal.classList.add(...next);
- }
-
- modal.dataset.appliedClass = next.join(' ');
-}
-
-function clearModalRootClass(modal) {
- if (!modal) return;
-
- const previous = (modal.dataset.appliedClass || '')
- .split(/\s+/)
- .filter(Boolean);
-
- if (previous.length) {
- modal.classList.remove(...previous);
- }
-
- modal.dataset.appliedClass = '';
-}
-
-function renderModalAside(dialog, items = [], show = false) {
- if (!dialog) return;
-
- const aside = dialog.querySelector(SELECTORS.modalAside);
- if (!aside) return;
-
- const signature = `${show ? '1' : '0'}|${items.map((item) => `${item.label}:${item.value}`).join('|')}`;
- if (aside.dataset.asideSignature === signature) {
- return;
- }
-
- const list = aside.querySelector(SELECTORS.modalAsideList);
- if (list) {
- const existing = new Map(
- Array.from(list.querySelectorAll('li[data-aside-key]')).map((li) => [li.dataset.asideKey, li])
- );
- const usedKeys = new Set();
-
- items.forEach(({ label, value }) => {
- const key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-');
- usedKeys.add(key);
-
- let li = existing.get(key);
- if (!li) {
- li = document.createElement('li');
- li.dataset.asideKey = key;
- li.classList.add('is-visible');
-
- const strong = document.createElement('strong');
- const text = document.createElement('span');
- li.appendChild(strong);
- li.appendChild(text);
- list.appendChild(li);
-
- li.addEventListener('animationend', () => {
- li.classList.remove('is-visible');
- }, { once: true });
- }
-
- const strong = li.querySelector('strong');
- const text = li.querySelector('span');
- const nextLabel = `${label}:`;
- const nextValue = ` ${value}`;
-
- if (strong && strong.textContent !== nextLabel) {
- strong.textContent = nextLabel;
- }
- if (text && text.textContent !== nextValue) {
- text.textContent = nextValue;
- }
- });
-
- existing.forEach((li, key) => {
- if (!usedKeys.has(key)) {
- li.remove();
- }
- });
- }
-
- aside.classList.toggle('is-visible', show);
- aside.setAttribute('aria-hidden', show ? 'false' : 'true');
- aside.dataset.asideSignature = signature;
-}
-
-function clearModalAside(dialog) {
- renderModalAside(dialog, [], false);
-}
-
/**
- *
* htmx/global
*/
@@ -183,14 +32,6 @@ document.addEventListener('htmx:configRequest', (evt) => {
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
})
-// modal htmx tracking
-document.addEventListener('htmx:beforeSwap', (evt) => {
- const target = evt.detail?.target || evt.target;
- if (target && target.id === 'modal') {
- target.dataset.prevUrl = window.location.href;
- syncModalRootClass(target);
- }
-});
/**
*
@@ -254,969 +95,6 @@ document.addEventListener('change', (event) => {
form.requestSubmit();
});
-// close event modal on back/forward navigation
-window.addEventListener('popstate', () => {
- if (!document.querySelector('article#calendar')) return;
-
- const dialog = document.querySelector('dialog');
- if (!dialog?.open) return;
-
- const modal = dialog.querySelector('#modal');
- if (!modal?.querySelector('[data-modal-kind="event"]')) return;
-
- dialog.close();
-});
-
-/**
- *
- * event form all-day toggle
- *
- * converts the datetime-local inputs into regular date inputs, maintaining the date
- */
-function initEventAllDayToggles(root = document) {
- const toDate = (value) => {
- if (!value) return '';
- return value.split('T')[0];
- };
-
- const withTime = (date, time) => {
- if (!date) return '';
- return `${date}T${time}`;
- };
-
- root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggleHost) => {
- const toggle = toggleHost.matches('input[type="checkbox"]')
- ? toggleHost
- : toggleHost.querySelector('input[type="checkbox"]');
-
- if (!toggle || toggle.__allDayWired) return;
- toggle.__allDayWired = true;
-
- const form = toggle.closest('form') || toggleHost.closest('form');
- if (!form) return;
-
- const start = form.querySelector(SELECTORS.eventStartInput);
- const end = form.querySelector(SELECTORS.eventEndInput);
- if (!start || !end) return;
-
- const apply = () => {
- if (toggle.checked) {
- if (start.type === 'datetime-local') {
- start.dataset.datetimeValue = start.value;
- }
- if (end.type === 'datetime-local') {
- end.dataset.datetimeValue = end.value;
- }
-
- const startDate = toDate(start.value);
- const endDate = toDate(end.value);
-
- start.type = 'date';
- end.type = 'date';
-
- if (startDate) start.value = startDate;
- if (endDate) end.value = endDate;
-
- if (start.value && end.value && end.value < start.value) {
- end.value = start.value;
- }
- } else {
- const startDate = toDate(start.value);
- const endDate = toDate(end.value);
- const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00';
- const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00';
-
- start.type = 'datetime-local';
- end.type = 'datetime-local';
-
- start.value = start.dataset.datetimeValue || withTime(startDate, startTime);
- end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime);
- }
- };
-
- toggle.addEventListener('change', apply);
- apply();
- });
-}
-
-/**
- *
- * recurrence preset selector
- */
-function initRecurrenceControls(root = document) {
- const sections = root.querySelectorAll(SELECTORS.recurrenceSection);
- if (!sections.length) return;
-
- const select = root.querySelector(SELECTORS.recurrenceFrequency);
- const intervalRow = root.querySelector(SELECTORS.recurrenceInterval);
- const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit);
- const monthModes = root.querySelectorAll(SELECTORS.monthlyMode);
- const monthDays = root.querySelector(SELECTORS.monthlyDays);
- const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday);
-
- if (!select) return;
-
- const unitMap = {
- daily: 'day',
- weekly: 'week',
- monthly: 'month',
- yearly: 'year',
- };
-
- const applyMonthlyMode = () => {
- if (!monthDays || !monthWeekday) return;
- const modeInput = Array.from(monthModes).find((input) => input.checked);
- const mode = modeInput?.value || 'days';
-
- if (mode === 'weekday') {
- monthDays.classList.add('hidden');
- monthWeekday.classList.remove('hidden');
- } else {
- monthDays.classList.remove('hidden');
- monthWeekday.classList.add('hidden');
- }
- };
-
- const apply = () => {
- const value = select.value;
- const show = value !== '';
-
- sections.forEach((section) => {
- const type = section.getAttribute('data-recurrence-section');
- if (!show) {
- section.classList.add('hidden');
- return;
- }
- section.classList.toggle('hidden', type !== value);
- });
-
- if (intervalRow) {
- intervalRow.classList.toggle('hidden', !show);
- }
-
- if (intervalUnit) {
- const unit = unitMap[value] || 'day';
- intervalUnit.textContent = unit ? `${unit}(s)` : '';
- }
-
- if (value === 'monthly') {
- applyMonthlyMode();
- }
- };
-
- select.addEventListener('change', apply);
- monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode));
-
- apply();
-}
-
-/**
- *
- * natural-language event parser (create modal header input)
- */
-function initNaturalEventParser(root = document) {
- const monthMap = {
- jan: 0, january: 0,
- feb: 1, february: 1,
- mar: 2, march: 2,
- apr: 3, april: 3,
- may: 4,
- jun: 5, june: 5,
- jul: 6, july: 6,
- aug: 7, august: 7,
- sep: 8, sept: 8, september: 8,
- oct: 9, october: 9,
- nov: 10, november: 10,
- dec: 11, december: 11,
- };
-
- const pad = (value) => String(value).padStart(2, '0');
-
- const toLocalDateInputValue = (date) => (
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
- );
-
- const toLocalDatetimeInputValue = (date) => (
- `${toLocalDateInputValue(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
- );
-
- const readDateInputValue = (value) => {
- if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
- const [year, month, day] = value.split('-').map((n) => parseInt(n, 10));
- if ([year, month, day].some((n) => Number.isNaN(n))) return null;
- return new Date(year, month - 1, day, 0, 0, 0, 0);
- };
-
- const readDatetimeInputValue = (value) => {
- if (!value || !value.includes('T')) return null;
- const [datePart, timePart] = value.split('T');
- if (!datePart || !timePart) return null;
- const [year, month, day] = datePart.split('-').map((n) => parseInt(n, 10));
- const [hours, minutes] = timePart.split(':').map((n) => parseInt(n, 10));
- if ([year, month, day, hours, minutes].some((n) => Number.isNaN(n))) return null;
- return new Date(year, month - 1, day, hours, minutes, 0, 0);
- };
-
- const formatDayLong = (date) => (
- date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
- );
-
- const formatTimeShort = (date) => (
- date
- .toLocaleTimeString(undefined, {
- hour: 'numeric',
- minute: date.getMinutes() === 0 ? undefined : '2-digit',
- })
- .toLowerCase()
- .replace(/\./g, '')
- .replace(/\s+/g, '')
- );
-
- const formatDateSummary = (startDate, endDate, allDay) => {
- if (!startDate) return '';
-
- const startDay = formatDayLong(startDate);
-
- if (allDay) {
- if (endDate && endDate.toDateString() !== startDate.toDateString()) {
- return `${startDay} to ${formatDayLong(endDate)}, all day`;
- }
- return `${startDay}, all day`;
- }
-
- const startTime = formatTimeShort(startDate);
- if (!endDate) {
- return `${startDay}, ${startTime}`;
- }
-
- const endTime = formatTimeShort(endDate);
- if (endDate.toDateString() === startDate.toDateString()) {
- return `${startDay}, ${startTime} to ${endTime}`;
- }
-
- return `${startDay}, ${startTime} to ${formatDayLong(endDate)}, ${endTime}`;
- };
-
- const parseDurationMinutes = (text) => {
- const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
- if (!match) return 60;
-
- const count = parseInt(match[1], 10);
- const unit = (match[2] || '').toLowerCase();
- if (Number.isNaN(count) || count <= 0) return 60;
-
- if (unit.startsWith('hour') || unit.startsWith('hr')) {
- return count * 60;
- }
-
- return count;
- };
-
- const parseDateToken = (text, now) => {
- const lower = text.toLowerCase();
-
- const todayMatch = lower.match(/\btoday\b/);
- if (todayMatch) {
- const date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
- return { date, index: todayMatch.index ?? 0 };
- }
-
- const tomorrowMatch = lower.match(/\btomorrow\b/);
- if (tomorrowMatch) {
- const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
- return { date, index: tomorrowMatch.index ?? 0 };
- }
-
- const monthMatch = text.match(/\b(?:on\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\b/);
- if (monthMatch) {
- const monthToken = (monthMatch[1] || '').toLowerCase();
- const month = monthMap[monthToken];
- const day = parseInt(monthMatch[2], 10);
- const explicitYear = monthMatch[3] ? parseInt(monthMatch[3], 10) : null;
-
- if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
- const thisYear = now.getFullYear();
- let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0);
- if (!explicitYear) {
- const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
- if (date < today) {
- date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
- }
- }
- return { date, index: monthMatch.index ?? 0 };
- }
- }
-
- const numericMatch = text.match(/\b(?:on\s+)?(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
- if (numericMatch) {
- const month = parseInt(numericMatch[1], 10) - 1;
- const day = parseInt(numericMatch[2], 10);
- const yearToken = numericMatch[3];
- const thisYear = now.getFullYear();
- let year = thisYear;
-
- if (yearToken) {
- year = parseInt(yearToken, 10);
- if (year < 100) year += 2000;
- }
-
- if (!Number.isNaN(month) && !Number.isNaN(day)) {
- let date = new Date(year, month, day, 0, 0, 0, 0);
- if (!yearToken) {
- const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
- if (date < today) {
- date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
- }
- }
- return { date, index: numericMatch.index ?? 0 };
- }
- }
-
- return null;
- };
-
- const parseTimeToken = (text) => {
- const lowered = text.toLowerCase();
- const noonIndex = lowered.indexOf(' at noon');
- if (noonIndex !== -1) {
- return { hours: 12, minutes: 0, index: noonIndex };
- }
-
- const midnightIndex = lowered.indexOf(' at midnight');
- if (midnightIndex !== -1) {
- return { hours: 0, minutes: 0, index: midnightIndex };
- }
-
- const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i);
- if (!match) return null;
-
- let hours = parseInt(match[1], 10);
- const minutes = parseInt(match[2] || '0', 10);
- const meridiem = (match[3] || '').toLowerCase();
-
- if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
-
- if (meridiem) {
- if (hours === 12 && meridiem === 'am') hours = 0;
- if (hours < 12 && meridiem === 'pm') hours += 12;
- } else if (hours > 23) {
- return null;
- }
-
- return {
- hours,
- minutes,
- index: match.index ?? 0,
- };
- };
-
- const parseRelativeToken = (text, now) => {
- const addOffset = (days, minutes) => {
- const date = new Date(now.getTime());
- date.setMinutes(date.getMinutes() + (days * 24 * 60) + minutes);
- return date;
- };
-
- let match = text.match(/\bin\s+(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\b/i);
- if (match) {
- const days = parseInt(match[1], 10);
- const minutes = parseInt(match[2] || '0', 10);
- if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
- return {
- date: addOffset(days, minutes),
- index: match.index ?? 0,
- };
- }
- }
-
- match = text.match(/\b(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\s+from\s+now\b/i);
- if (match) {
- const days = parseInt(match[1], 10);
- const minutes = parseInt(match[2] || '0', 10);
- if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
- return {
- date: addOffset(days, minutes),
- index: match.index ?? 0,
- };
- }
- }
-
- match = text.match(/\bin\s+(\d+)\s+minutes?\b/i);
- if (match) {
- const minutes = parseInt(match[1], 10);
- if (!Number.isNaN(minutes)) {
- return {
- date: addOffset(0, minutes),
- index: match.index ?? 0,
- };
- }
- }
-
- match = text.match(/\b(\d+)\s+minutes?\s+from\s+now\b/i);
- if (match) {
- const minutes = parseInt(match[1], 10);
- if (!Number.isNaN(minutes)) {
- return {
- date: addOffset(0, minutes),
- index: match.index ?? 0,
- };
- }
- }
-
- return null;
- };
-
- const weekdayCodeMap = {
- su: 'SU', sun: 'SU', sunday: 'SU', sundays: 'SU',
- mo: 'MO', mon: 'MO', monday: 'MO', mondays: 'MO',
- tu: 'TU', tue: 'TU', tues: 'TU', tuesday: 'TU', tuesdays: 'TU',
- we: 'WE', wed: 'WE', wednesday: 'WE', wednesdays: 'WE',
- th: 'TH', thu: 'TH', thur: 'TH', thurs: 'TH', thursday: 'TH', thursdays: 'TH',
- fr: 'FR', fri: 'FR', friday: 'FR', fridays: 'FR',
- sa: 'SA', sat: 'SA', saturday: 'SA', saturdays: 'SA',
- };
-
- const weekdayNameMap = {
- SU: 'Sunday',
- MO: 'Monday',
- TU: 'Tuesday',
- WE: 'Wednesday',
- TH: 'Thursday',
- FR: 'Friday',
- SA: 'Saturday',
- };
-
- const weekdayCodeFromDate = (date) => ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][date.getDay()];
-
- const parseRecurrenceToken = (text) => {
- const repeatMatch = text.match(/\brepeats?\b([\s\S]*)$/i);
- if (!repeatMatch) return null;
-
- const tail = (repeatMatch[1] || '').trim().toLowerCase();
- const recurrence = {
- frequency: null,
- interval: 1,
- weekdays: [],
- index: repeatMatch.index ?? 0,
- };
-
- const everyNumbered = tail.match(/\bevery\s+(\d+)\s*(day|week|month|year)s?\b/i);
- if (everyNumbered) {
- const count = parseInt(everyNumbered[1], 10);
- const unit = everyNumbered[2].toLowerCase();
- if (!Number.isNaN(count) && count > 0) {
- recurrence.interval = count;
- }
- recurrence.frequency = {
- day: 'daily',
- week: 'weekly',
- month: 'monthly',
- year: 'yearly',
- }[unit] || null;
- } else if (/\b(daily|every day)\b/i.test(tail)) {
- recurrence.frequency = 'daily';
- } else if (/\b(weekly|every week)\b/i.test(tail)) {
- recurrence.frequency = 'weekly';
- } else if (/\b(monthly|every month)\b/i.test(tail)) {
- recurrence.frequency = 'monthly';
- } else if (/\b(yearly|every year)\b/i.test(tail)) {
- recurrence.frequency = 'yearly';
- }
-
- if (!recurrence.frequency) {
- return null;
- }
-
- if (recurrence.frequency === 'weekly') {
- const onMatch = tail.match(/\bon\s+(.+)$/i);
- if (onMatch) {
- const weekdays = [];
- const tokens = onMatch[1]
- .replace(/[,/]/g, ' ')
- .replace(/\band\b/g, ' ')
- .split(/\s+/)
- .map((token) => token.trim().toLowerCase().replace(/[^a-z]/g, ''))
- .filter(Boolean);
-
- tokens.forEach((token) => {
- const code = weekdayCodeMap[token];
- if (code && !weekdays.includes(code)) {
- weekdays.push(code);
- }
- });
-
- recurrence.weekdays = weekdays;
- }
- }
-
- return recurrence;
- };
-
- const parseLocationToken = (text) => {
- const lower = text.toLowerCase();
- const index = lower.lastIndexOf(' at ');
- if (index === -1) return null;
-
- const candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, '');
- if (!candidate) return null;
- if (/^\d{1,2}(?::\d{2})?\s*(am|pm)?\b/i.test(candidate)) return null;
- if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null;
-
- return candidate;
- };
-
- const parseTitleToken = (text, dateToken, timeToken, relativeToken, recurrenceToken, locationToken) => {
- const boundaries = [];
- if (dateToken) boundaries.push(dateToken.index);
- if (timeToken) boundaries.push(timeToken.index);
- if (relativeToken) boundaries.push(relativeToken.index);
- if (recurrenceToken) boundaries.push(recurrenceToken.index);
-
- const lower = text.toLowerCase();
- const durationIndex = lower.indexOf(' for ');
- if (durationIndex !== -1) boundaries.push(durationIndex);
-
- if (locationToken) {
- const locationIndex = lower.lastIndexOf(' at ');
- if (locationIndex !== -1) boundaries.push(locationIndex);
- }
-
- if (!boundaries.length) {
- return text.trim();
- }
-
- const end = Math.min(...boundaries);
- return text.slice(0, end).trim().replace(/[,:;.\-]+$/, '').trim();
- };
-
- const parseNaturalEventText = (text, baseDate) => {
- const trimmed = (text || '').trim();
- if (!trimmed) return null;
-
- const relativeToken = parseRelativeToken(trimmed, baseDate);
- const recurrenceToken = parseRecurrenceToken(trimmed);
- const dateToken = parseDateToken(trimmed, baseDate);
- const timeToken = parseTimeToken(trimmed);
- const durationMinutes = parseDurationMinutes(trimmed);
- const location = parseLocationToken(trimmed);
- const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, recurrenceToken, location);
- const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
-
- return {
- title,
- location,
- dateToken,
- timeToken,
- relativeToken,
- recurrenceToken,
- durationMinutes,
- allDay,
- };
- };
-
- const findFormFromNaturalInput = (input) => {
- const dialog = input.closest('dialog');
- if (dialog) {
- return dialog.querySelector('#event-form');
- }
-
- const modal = input.closest(SELECTORS.modalContent);
- if (modal) {
- return modal.querySelector('#event-form');
- }
-
- return document.querySelector('#event-form');
- };
-
- const enableNaturalCollapsedMode = (input) => {
- const dialog = input.closest('dialog');
- const modal = dialog?.querySelector(SELECTORS.modalContent);
- if (!dialog || !modal || modal.classList.contains('natural-expanded')) return;
-
- modal.classList.add('natural-collapsed');
- renderModalAside(dialog, [], true);
- };
-
- const clearRecurrenceInputs = (form) => {
- const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
- const intervalInput = form.querySelector('input[name="repeat_interval"]');
- const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
- const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
- const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
-
- if (frequencyInput) {
- frequencyInput.value = '';
- frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
- }
- if (intervalInput) {
- intervalInput.value = '1';
- }
- weekdayInputs.forEach((input) => {
- input.checked = false;
- });
- monthlyModeInputs.forEach((input) => {
- input.checked = input.value === 'days';
- });
- monthDayInputs.forEach((input) => {
- input.checked = false;
- });
- };
-
- const applyRecurrenceInputs = (form, recurrenceToken, anchorDate) => {
- const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
- const intervalInput = form.querySelector('input[name="repeat_interval"]');
- const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
- const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
- const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
-
- if (!frequencyInput || !recurrenceToken?.frequency) {
- return;
- }
-
- frequencyInput.value = recurrenceToken.frequency;
- frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
-
- if (intervalInput) {
- intervalInput.value = String(recurrenceToken.interval || 1);
- }
-
- if (recurrenceToken.frequency === 'weekly') {
- let weekdays = recurrenceToken.weekdays || [];
- if (!weekdays.length && anchorDate) {
- weekdays = [weekdayCodeFromDate(anchorDate)];
- }
- weekdayInputs.forEach((input) => {
- input.checked = weekdays.includes(input.value);
- });
- } else {
- weekdayInputs.forEach((input) => {
- input.checked = false;
- });
- }
-
- if (recurrenceToken.frequency === 'monthly') {
- monthlyModeInputs.forEach((input) => {
- input.checked = input.value === 'days';
- });
-
- const dayOfMonth = anchorDate ? anchorDate.getDate() : null;
- monthDayInputs.forEach((input) => {
- input.checked = dayOfMonth ? Number(input.value) === dayOfMonth : false;
- });
- } else {
- monthDayInputs.forEach((input) => {
- input.checked = false;
- });
- }
- };
-
- const recurrenceSummary = (recurrenceToken, anchorDate) => {
- if (!recurrenceToken?.frequency) return '';
-
- const interval = recurrenceToken.interval || 1;
-
- if (recurrenceToken.frequency === 'daily') {
- return interval === 1 ? 'Daily' : `Every ${interval} days`;
- }
-
- if (recurrenceToken.frequency === 'weekly') {
- const days = (recurrenceToken.weekdays || []).length
- ? recurrenceToken.weekdays
- : (anchorDate ? [weekdayCodeFromDate(anchorDate)] : []);
- const dayLabels = days.map((code) => weekdayNameMap[code] || code);
- const head = interval === 1 ? 'Every week' : `Every ${interval} weeks`;
- return dayLabels.length ? `${head} on ${dayLabels.join(' and ')}` : head;
- }
-
- if (recurrenceToken.frequency === 'monthly') {
- return interval === 1 ? 'Monthly' : `Every ${interval} months`;
- }
-
- if (recurrenceToken.frequency === 'yearly') {
- return interval === 1 ? 'Yearly' : `Every ${interval} years`;
- }
-
- return '';
- };
-
- const applyParsedData = (input) => {
- const dialog = input.closest('dialog');
- const modal = dialog?.querySelector(SELECTORS.modalContent);
- const form = findFormFromNaturalInput(input);
- if (!form) return;
-
- const titleInput = form.querySelector(SELECTORS.eventTitleInput);
- const locationInput = form.querySelector(SELECTORS.eventLocationInput);
- const startInput = form.querySelector(SELECTORS.eventStartInput);
- const endInput = form.querySelector(SELECTORS.eventEndInput);
- const allDayToggleHost = form.querySelector(SELECTORS.eventAllDayToggle);
- const allDayToggle = allDayToggleHost?.matches('input[type="checkbox"]')
- ? allDayToggleHost
- : allDayToggleHost?.querySelector('input[type="checkbox"]');
-
- if (!titleInput || !startInput || !endInput) return;
-
- const parsed = parseNaturalEventText(input.value, new Date());
- if (!parsed) {
- if (modal?.classList.contains('natural-collapsed')) {
- renderModalAside(dialog, [], true);
- }
- return;
- }
-
- if (parsed.title) {
- titleInput.value = parsed.title;
- }
-
- if (parsed.location && locationInput) {
- locationInput.value = parsed.location;
- }
-
- const existingStart = startInput.type === 'date'
- ? readDateInputValue(startInput.value)
- : readDatetimeInputValue(startInput.value);
- const baseDate = parsed.relativeToken?.date
- ? new Date(parsed.relativeToken.date.getTime())
- : parsed.dateToken?.date
- ? new Date(parsed.dateToken.date.getTime())
- : (existingStart ? new Date(existingStart.getTime()) : new Date());
-
- if (parsed.allDay) {
- if (allDayToggle && !allDayToggle.checked) {
- allDayToggle.checked = true;
- allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- const dateOnly = toLocalDateInputValue(baseDate);
- startInput.value = dateOnly;
- endInput.value = dateOnly;
- } else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) {
- if (allDayToggle?.checked) {
- allDayToggle.checked = false;
- allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
- const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
- baseDate.setHours(hours, minutes, 0, 0);
-
- const end = new Date(baseDate.getTime());
- end.setMinutes(end.getMinutes() + parsed.durationMinutes);
-
- startInput.value = toLocalDatetimeInputValue(baseDate);
- endInput.value = toLocalDatetimeInputValue(end);
- }
-
- if (parsed.recurrenceToken) {
- applyRecurrenceInputs(form, parsed.recurrenceToken, baseDate);
- input.dataset.nlRecurrenceApplied = '1';
- } else if (input.dataset.nlRecurrenceApplied === '1') {
- clearRecurrenceInputs(form);
- input.dataset.nlRecurrenceApplied = '0';
- }
-
- const summaryItems = [];
- if (parsed.title) {
- summaryItems.push({ label: 'Title', value: parsed.title });
- }
- if (parsed.location) {
- summaryItems.push({ label: 'Location', value: parsed.location });
- }
- if (parsed.dateToken || parsed.timeToken || parsed.relativeToken || parsed.allDay) {
- const summaryStart = startInput.type === 'date'
- ? readDateInputValue(startInput.value)
- : readDatetimeInputValue(startInput.value);
- const summaryEnd = endInput.type === 'date'
- ? readDateInputValue(endInput.value)
- : readDatetimeInputValue(endInput.value);
- const allDayState = Boolean(allDayToggle?.checked);
- const summaryDate = formatDateSummary(summaryStart, summaryEnd, allDayState);
-
- if (summaryDate) {
- summaryItems.push({ label: 'Date', value: summaryDate });
- }
- }
- if (parsed.recurrenceToken) {
- const repeat = recurrenceSummary(parsed.recurrenceToken, baseDate);
- if (repeat) {
- summaryItems.push({ label: 'Repeat', value: repeat });
- }
- }
-
- if (modal?.classList.contains('natural-collapsed')) {
- renderModalAside(dialog, summaryItems, true);
- }
- };
-
- if (!document.__naturalEventDelegated) {
- document.__naturalEventDelegated = true;
-
- document.addEventListener('input', (event) => {
- const input = event.target?.closest?.(SELECTORS.naturalEventInput);
- if (!input) return;
- applyParsedData(input);
- });
-
- document.addEventListener('change', (event) => {
- const input = event.target?.closest?.(SELECTORS.naturalEventInput);
- if (!input) return;
- applyParsedData(input);
- });
-
- document.addEventListener('click', (event) => {
- const button = event.target?.closest?.(SELECTORS.modalExpand);
- if (!button) return;
-
- const dialog = button.closest('dialog');
- const modal = dialog?.querySelector(SELECTORS.modalContent);
- if (!dialog || !modal) return;
-
- modal.classList.remove('natural-collapsed');
- modal.classList.add('natural-expanded');
- clearModalAside(dialog);
- });
- }
-
- root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
- enableNaturalCollapsedMode(input);
-
- if (input.value?.trim()) {
- applyParsedData(input);
- }
- });
-}
-
-/**
- *
- * modal behaviors (backdrop close + url restore)
- */
-function initModalHandlers(root = document) {
- const dialog = root.querySelector(SELECTORS.modalDialog);
- if (!dialog || dialog.__modalWired) return;
- dialog.__modalWired = true;
-
- dialog.addEventListener('click', (event) => {
- if (event.target === dialog) {
- dialog.close();
- }
- });
-
- dialog.addEventListener('close', () => {
- const modal = dialog.querySelector(SELECTORS.modalContent);
- if (!modal) return;
-
- const isEvent = modal.querySelector('[data-modal-kind="event"]');
- const prevUrl = modal.dataset.prevUrl;
- modal.classList.remove('natural-collapsed', 'natural-expanded');
- modal.innerHTML = '';
- clearModalRootClass(modal);
- clearModalAside(dialog);
-
- if (isEvent && prevUrl) {
- history.replaceState({}, '', prevUrl);
- }
- });
-}
-
-/**
- * tabs (simple modal panels)
- */
-function initTabs(root = document) {
- root.querySelectorAll(SELECTORS.tabsRoot).forEach((tabs) => {
- if (tabs.__tabsWired) return;
- tabs.__tabsWired = true;
-
- const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton));
- const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel));
- if (!tabEls.length || !panels.length) return;
-
- const getPanelForTab = (tab, index) => {
- const controls = tab.getAttribute('aria-controls');
-
- if (controls) {
- const panelById = tabs.querySelector(`[role="tabpanel"]#${controls}`);
- if (panelById) return panelById;
- }
-
- return panels[index] || null;
- };
-
- const panelByTab = new Map();
- tabEls.forEach((tab, index) => {
- const panel = getPanelForTab(tab, index);
- if (panel) {
- panelByTab.set(tab, panel);
- }
-
- //
is not focusable by default.
- if (!tab.hasAttribute('tabindex')) {
- tab.setAttribute('tabindex', '-1');
- }
- });
-
- const activate = (activeTab, moveFocus = false) => {
- tabEls.forEach((tab) => {
- const isActive = tab === activeTab;
- tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
- tab.setAttribute('tabindex', isActive ? '0' : '-1');
- });
-
- panels.forEach((panel) => {
- panel.hidden = true;
- });
-
- const panel = panelByTab.get(activeTab);
- if (panel) {
- panel.hidden = false;
- }
-
- if (moveFocus && typeof activeTab.focus === 'function') {
- activeTab.focus();
- }
- };
-
- tabs.addEventListener('click', (event) => {
- const tab = event.target.closest(SELECTORS.tabButton);
- if (!tab || !tabs.contains(tab)) return;
-
- event.preventDefault();
- activate(tab, false);
- });
-
- tabs.addEventListener('keydown', (event) => {
- const currentTab = event.target.closest(SELECTORS.tabButton);
- if (!currentTab || !tabs.contains(currentTab)) return;
-
- const currentIndex = tabEls.indexOf(currentTab);
- if (currentIndex === -1) return;
-
- let nextIndex = null;
- const horizontal = ['ArrowLeft', 'ArrowRight'];
- const vertical = ['ArrowUp', 'ArrowDown'];
-
- if (event.key === 'Home') nextIndex = 0;
- if (event.key === 'End') nextIndex = tabEls.length - 1;
- if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
- nextIndex = (currentIndex - 1 + tabEls.length) % tabEls.length;
- }
- if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
- nextIndex = (currentIndex + 1) % tabEls.length;
- }
-
- const orientation = tabs.querySelector('[role="tablist"]')?.getAttribute('aria-orientation') || 'horizontal';
- if (orientation === 'vertical' && horizontal.includes(event.key)) return;
- if (orientation !== 'vertical' && vertical.includes(event.key)) return;
-
- if (nextIndex === null) return;
-
- event.preventDefault();
- activate(tabEls[nextIndex], true);
- });
-
- const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || tabEls[0];
- if (current) activate(current, false);
- });
-}
/**
*
@@ -1250,164 +128,8 @@ function initTimeViewAutoScroll(root = document)
calendar.dataset.autoscrolled = '1';
}
-/**
- *
- * attendees form controls (contact lookup + email fallback)
- */
-function initAttendeeControls(root = document) {
- const normalizeEmail = (value) => String(value || '').trim().toLowerCase();
- const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value));
-
- const formatDisplay = (name, email) => {
- const n = String(name || '').trim();
- const e = normalizeEmail(email);
- if (!e) return '';
- return n ? `${n} <${e}>` : e;
- };
-
- root.querySelectorAll(SELECTORS.attendeesRoot).forEach((container) => {
- if (container.__attendeesWired) return;
- container.__attendeesWired = true;
-
- const list = container.querySelector(SELECTORS.attendeesList);
- const template = container.querySelector(SELECTORS.attendeeTemplate);
- const lookup = container.querySelector(SELECTORS.attendeeLookup);
- const suggestions = container.querySelector(SELECTORS.attendeeSuggestions);
- const addManual = container.querySelector(SELECTORS.attendeeAddManual);
- if (!list || !template || !lookup) return;
-
- const nextIndex = () => {
- const current = parseInt(container.dataset.nextIndex || '0', 10);
- const safe = Number.isNaN(current) ? 0 : current;
- container.dataset.nextIndex = String(safe + 1);
- return safe;
- };
-
- const updateRole = (row) => {
- const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
- const roleInput = row.querySelector(SELECTORS.attendeeRole);
- if (!optionalInput || !roleInput) return;
- roleInput.value = optionalInput.checked ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT';
- };
-
- const wireRow = (row) => {
- if (!row || row.__attendeeRowWired) return;
- row.__attendeeRowWired = true;
-
- const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
- if (optionalInput) {
- optionalInput.addEventListener('change', () => updateRole(row));
- }
-
- updateRole(row);
- };
-
- const hasEmail = (email) => {
- const normalized = normalizeEmail(email);
- if (!normalized) return true;
-
- return Array.from(list.querySelectorAll(`${SELECTORS.attendeeRow} ${SELECTORS.attendeeEmail}`))
- .some((input) => normalizeEmail(input.value) === normalized);
- };
-
- const createRow = () => {
- const index = nextIndex();
- const html = template.innerHTML.replaceAll('__INDEX__', String(index)).trim();
- if (!html) return null;
-
- const fragment = document.createElement('div');
- fragment.innerHTML = html;
- return fragment.firstElementChild;
- };
-
- const addAttendee = ({ email, name = '', attendeeUri = '', verified = false }) => {
- const normalizedEmail = normalizeEmail(email);
- if (!isValidEmail(normalizedEmail)) return false;
- if (hasEmail(normalizedEmail)) return false;
-
- const row = createRow();
- if (!row) return false;
-
- const uriInput = row.querySelector(SELECTORS.attendeeUri);
- const emailInput = row.querySelector(SELECTORS.attendeeEmail);
- const nameInput = row.querySelector(SELECTORS.attendeeName);
- const display = row.querySelector(SELECTORS.attendeeDisplay);
- const verifiedEl = row.querySelector(SELECTORS.attendeeVerified);
-
- if (uriInput) uriInput.value = attendeeUri || `mailto:${normalizedEmail}`;
- if (emailInput) emailInput.value = normalizedEmail;
- if (nameInput) nameInput.value = String(name || '').trim();
- if (display) display.textContent = formatDisplay(name, normalizedEmail);
- if (verifiedEl) {
- verifiedEl.classList.toggle('hidden', !verified);
- }
-
- list.appendChild(row);
- wireRow(row);
- return true;
- };
-
- const addFromLookupIfEmail = () => {
- const raw = lookup.value;
- if (!isValidEmail(raw)) return false;
-
- const ok = addAttendee({
- email: raw,
- attendeeUri: `mailto:${normalizeEmail(raw)}`,
- verified: false,
- });
-
- if (ok) {
- lookup.value = '';
- if (suggestions) suggestions.innerHTML = '';
- }
-
- return ok;
- };
-
- container.addEventListener('click', (event) => {
- const removeButton = event.target.closest(SELECTORS.attendeeRemove);
- if (removeButton && container.contains(removeButton)) {
- event.preventDefault();
- const row = removeButton.closest(SELECTORS.attendeeRow);
- row?.remove();
- return;
- }
-
- const pickButton = event.target.closest(SELECTORS.attendeePick);
- if (pickButton && container.contains(pickButton)) {
- event.preventDefault();
- const email = pickButton.dataset.attendeeEmail || '';
- const name = pickButton.dataset.attendeeName || '';
- const attendeeUri = pickButton.dataset.attendeeUri || '';
- const verified = pickButton.dataset.attendeeVerified === '1';
-
- const ok = addAttendee({ email, name, attendeeUri, verified });
- if (ok) {
- lookup.value = '';
- if (suggestions) suggestions.innerHTML = '';
- }
- return;
- }
-
- if (addManual && event.target.closest(SELECTORS.attendeeAddManual)) {
- event.preventDefault();
- addFromLookupIfEmail();
- }
- });
-
- lookup.addEventListener('keydown', (event) => {
- if (event.key !== 'Enter') return;
- event.preventDefault();
- addFromLookupIfEmail();
- });
-
- list.querySelectorAll(SELECTORS.attendeeRow).forEach(wireRow);
- });
-}
/**
- *
* calendar sidebar expand toggle
*/
@@ -1517,68 +239,6 @@ function initColorPickers(root = document) {
root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
}
-/**
- *
- * calendar picker (custom dropdown with color chip)
- */
-function initCalendarPickers(root = document) {
- root.querySelectorAll(SELECTORS.calendarPicker).forEach((picker) => {
- if (picker.__calendarPickerWired) return;
- picker.__calendarPickerWired = true;
-
- const input = picker.querySelector(SELECTORS.calendarPickerInput);
- const toggle = picker.querySelector(SELECTORS.calendarPickerToggle);
- const menu = picker.querySelector(SELECTORS.calendarPickerMenu);
- const label = picker.querySelector(SELECTORS.calendarPickerLabel);
- const colorDot = picker.querySelector(SELECTORS.calendarPickerColorDot);
-
- if (!input || !toggle || !menu || !label || !colorDot) return;
-
- const close = () => {
- menu.classList.add('hidden');
- toggle.setAttribute('aria-expanded', 'false');
- };
-
- const open = () => {
- menu.classList.remove('hidden');
- toggle.setAttribute('aria-expanded', 'true');
- };
-
- toggle.addEventListener('click', (event) => {
- event.preventDefault();
- if (menu.classList.contains('hidden')) open();
- else close();
- });
-
- picker.addEventListener('click', (event) => {
- const option = event.target.closest(SELECTORS.calendarPickerOption);
- if (!option || !picker.contains(option)) return;
-
- event.preventDefault();
-
- const uri = option.dataset.calendarPickerUri || '';
- const name = option.dataset.calendarPickerName || '';
- const color = option.dataset.calendarPickerColor || '#64748b';
-
- input.value = uri;
- label.textContent = name;
- colorDot.style.backgroundColor = color;
- close();
- });
-
- document.addEventListener('click', (event) => {
- if (!picker.contains(event.target)) {
- close();
- }
- });
-
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- close();
- }
- });
- });
-}
/**
*
@@ -1716,20 +376,17 @@ window.addEventListener('resize', () => {
monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100);
});
+
+
/**
*
* initialization
*/
function initUI() {
- initCalendarPickers();
+ initEventModalGlobals();
+ initEventModalUI();
initColorPickers();
- initNaturalEventParser();
- initEventAllDayToggles();
- initRecurrenceControls();
- initAttendeeControls();
- initModalHandlers();
- initTabs();
initTimeViewAutoScroll();
initMonthOverflow();
}
@@ -1740,19 +397,11 @@ document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => {
const target = e.detail?.target || e.target;
- if (target && target.id === 'modal') {
- syncModalRootClass(target);
- target.closest('dialog')?.showModal();
- }
- initColorPickers(e.target);
- initCalendarPickers(e.target);
- initNaturalEventParser(e.target);
- initEventAllDayToggles(e.target);
- initRecurrenceControls(e.target);
- initAttendeeControls(e.target);
- initModalHandlers(e.target);
- initTabs(e.target);
- initTimeViewAutoScroll(e.target);
- initMonthOverflow(e.target);
+ handleEventModalAfterSwap(target);
+
+ initEventModalUI(target);
+ initColorPickers(target);
+ initTimeViewAutoScroll(target);
+ initMonthOverflow(target);
});
diff --git a/resources/js/modules/event-modal.js b/resources/js/modules/event-modal.js
new file mode 100644
index 0000000..8c11d25
--- /dev/null
+++ b/resources/js/modules/event-modal.js
@@ -0,0 +1,1398 @@
+const SELECTORS = {
+ calendarToggle: '.calendar-toggle',
+ calendarViewForm: '#calendar-view',
+ calendarExpandToggle: '[data-calendar-expand]',
+ colorPicker: '[data-colorpicker]',
+ colorPickerColor: '[data-colorpicker-color]',
+ colorPickerHex: '[data-colorpicker-hex]',
+ colorPickerRandom: '[data-colorpicker-random]',
+ calendarPicker: '[data-calendar-picker]',
+ calendarPickerInput: '[data-calendar-picker-input]',
+ calendarPickerToggle: '[data-calendar-picker-toggle]',
+ calendarPickerMenu: '[data-calendar-picker-menu]',
+ calendarPickerLabel: '[data-calendar-picker-label]',
+ calendarPickerColorDot: '[data-calendar-picker-color]',
+ calendarPickerOption: '[data-calendar-picker-option]',
+ eventAllDayToggle: '[data-all-day-toggle]',
+ eventStartInput: '[data-event-start]',
+ eventEndInput: '[data-event-end]',
+ eventTitleInput: 'input[name="title"]',
+ eventLocationInput: 'input[name="location"]',
+ modalDialog: 'dialog',
+ modalContent: '#modal',
+ modalClassSource: '[data-modal-class]',
+ modalAside: '#modal-aside',
+ modalAsideList: '[data-modal-aside-list]',
+ modalExpand: '[data-modal-expand]',
+ monthDay: '.calendar.month .day',
+ monthDayEvent: 'a.event',
+ monthDayMore: '[data-day-more]',
+ monthDayMoreWrap: '.more-events',
+ monthlyMode: '[data-monthly-mode]',
+ monthlyDays: '[data-monthly-days]',
+ monthlyWeekday: '[data-monthly-weekday]',
+ naturalEventInput: '[data-natural-event-input]',
+ recurrenceFrequency: '[data-recurrence-frequency]',
+ recurrenceInterval: '[data-recurrence-interval]',
+ recurrenceUnit: '[data-recurrence-unit]',
+ recurrenceSection: '[data-recurrence-section]',
+ tabsRoot: '[data-tabs]',
+ tabButton: '[role=\"tab\"]',
+ tabPanel: '[role=\"tabpanel\"]',
+ attendeesRoot: '[data-attendees]',
+ attendeesList: '[data-attendees-list]',
+ attendeeTemplate: 'template[data-attendee-template]',
+ attendeeLookup: '[data-attendee-lookup]',
+ attendeeSuggestions: '#attendee-suggestions',
+ attendeeAddManual: '[data-attendee-add-manual]',
+ attendeePick: '[data-attendee-pick]',
+ attendeeRemove: '[data-attendee-remove]',
+ attendeeRow: '[data-attendee-row]',
+ attendeeRole: '[data-attendee-role]',
+ attendeeName: '[data-attendee-name]',
+ attendeeOptional: '[data-attendee-optional]',
+ attendeeDisplay: '[data-attendee-display]',
+ attendeeVerified: '[data-attendee-verified]',
+ attendeeEmail: '[data-attendee-email]',
+ attendeeUri: '[data-attendee-uri]',
+};
+
+function syncModalRootClass(modal) {
+ if (!modal) return;
+
+ const previous = (modal.dataset.appliedClass || '')
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (previous.length) {
+ modal.classList.remove(...previous);
+ }
+
+ const source = modal.querySelector(SELECTORS.modalClassSource);
+ const next = (source?.dataset?.modalClass || '')
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (next.length) {
+ modal.classList.add(...next);
+ }
+
+ modal.dataset.appliedClass = next.join(' ');
+}
+
+function clearModalRootClass(modal) {
+ if (!modal) return;
+
+ const previous = (modal.dataset.appliedClass || '')
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (previous.length) {
+ modal.classList.remove(...previous);
+ }
+
+ modal.dataset.appliedClass = '';
+}
+
+function renderModalAside(dialog, items = [], show = false) {
+ if (!dialog) return;
+
+ const aside = dialog.querySelector(SELECTORS.modalAside);
+ if (!aside) return;
+
+ const signature = `${show ? '1' : '0'}|${items.map((item) => `${item.label}:${item.value}`).join('|')}`;
+ if (aside.dataset.asideSignature === signature) {
+ return;
+ }
+
+ const list = aside.querySelector(SELECTORS.modalAsideList);
+ if (list) {
+ const existing = new Map(
+ Array.from(list.querySelectorAll('li[data-aside-key]')).map((li) => [li.dataset.asideKey, li])
+ );
+ const usedKeys = new Set();
+
+ items.forEach(({ label, value }) => {
+ const key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-');
+ usedKeys.add(key);
+
+ let li = existing.get(key);
+ if (!li) {
+ li = document.createElement('li');
+ li.dataset.asideKey = key;
+ li.classList.add('is-visible');
+
+ const strong = document.createElement('strong');
+ const text = document.createElement('span');
+ li.appendChild(strong);
+ li.appendChild(text);
+ list.appendChild(li);
+
+ li.addEventListener('animationend', () => {
+ li.classList.remove('is-visible');
+ }, { once: true });
+ }
+
+ const strong = li.querySelector('strong');
+ const text = li.querySelector('span');
+ const nextLabel = `${label}:`;
+ const nextValue = ` ${value}`;
+
+ if (strong && strong.textContent !== nextLabel) {
+ strong.textContent = nextLabel;
+ }
+ if (text && text.textContent !== nextValue) {
+ text.textContent = nextValue;
+ }
+ });
+
+ existing.forEach((li, key) => {
+ if (!usedKeys.has(key)) {
+ li.remove();
+ }
+ });
+ }
+
+ aside.classList.toggle('is-visible', show);
+ aside.setAttribute('aria-hidden', show ? 'false' : 'true');
+ aside.dataset.asideSignature = signature;
+}
+
+function clearModalAside(dialog) {
+ renderModalAside(dialog, [], false);
+}
+
+function initEventAllDayToggles(root = document) {
+ const toDate = (value) => {
+ if (!value) return '';
+ return value.split('T')[0];
+ };
+
+ const withTime = (date, time) => {
+ if (!date) return '';
+ return `${date}T${time}`;
+ };
+
+ root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggleHost) => {
+ const toggle = toggleHost.matches('input[type="checkbox"]')
+ ? toggleHost
+ : toggleHost.querySelector('input[type="checkbox"]');
+
+ if (!toggle || toggle.__allDayWired) return;
+ toggle.__allDayWired = true;
+
+ const form = toggle.closest('form') || toggleHost.closest('form');
+ if (!form) return;
+
+ const start = form.querySelector(SELECTORS.eventStartInput);
+ const end = form.querySelector(SELECTORS.eventEndInput);
+ if (!start || !end) return;
+
+ const apply = () => {
+ if (toggle.checked) {
+ if (start.type === 'datetime-local') {
+ start.dataset.datetimeValue = start.value;
+ }
+ if (end.type === 'datetime-local') {
+ end.dataset.datetimeValue = end.value;
+ }
+
+ const startDate = toDate(start.value);
+ const endDate = toDate(end.value);
+
+ start.type = 'date';
+ end.type = 'date';
+
+ if (startDate) start.value = startDate;
+ if (endDate) end.value = endDate;
+
+ if (start.value && end.value && end.value < start.value) {
+ end.value = start.value;
+ }
+ } else {
+ const startDate = toDate(start.value);
+ const endDate = toDate(end.value);
+ const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00';
+ const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00';
+
+ start.type = 'datetime-local';
+ end.type = 'datetime-local';
+
+ start.value = start.dataset.datetimeValue || withTime(startDate, startTime);
+ end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime);
+ }
+ };
+
+ toggle.addEventListener('change', apply);
+ apply();
+ });
+}
+
+/**
+ *
+ * recurrence preset selector
+ */
+function initRecurrenceControls(root = document) {
+ const sections = root.querySelectorAll(SELECTORS.recurrenceSection);
+ if (!sections.length) return;
+
+ const select = root.querySelector(SELECTORS.recurrenceFrequency);
+ const intervalRow = root.querySelector(SELECTORS.recurrenceInterval);
+ const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit);
+ const monthModes = root.querySelectorAll(SELECTORS.monthlyMode);
+ const monthDays = root.querySelector(SELECTORS.monthlyDays);
+ const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday);
+
+ if (!select) return;
+
+ const unitMap = {
+ daily: 'day',
+ weekly: 'week',
+ monthly: 'month',
+ yearly: 'year',
+ };
+
+ const applyMonthlyMode = () => {
+ if (!monthDays || !monthWeekday) return;
+ const modeInput = Array.from(monthModes).find((input) => input.checked);
+ const mode = modeInput?.value || 'days';
+
+ if (mode === 'weekday') {
+ monthDays.classList.add('hidden');
+ monthWeekday.classList.remove('hidden');
+ } else {
+ monthDays.classList.remove('hidden');
+ monthWeekday.classList.add('hidden');
+ }
+ };
+
+ const apply = () => {
+ const value = select.value;
+ const show = value !== '';
+
+ sections.forEach((section) => {
+ const type = section.getAttribute('data-recurrence-section');
+ if (!show) {
+ section.classList.add('hidden');
+ return;
+ }
+ section.classList.toggle('hidden', type !== value);
+ });
+
+ if (intervalRow) {
+ intervalRow.classList.toggle('hidden', !show);
+ }
+
+ if (intervalUnit) {
+ const unit = unitMap[value] || 'day';
+ intervalUnit.textContent = unit ? `${unit}(s)` : '';
+ }
+
+ if (value === 'monthly') {
+ applyMonthlyMode();
+ }
+ };
+
+ select.addEventListener('change', apply);
+ monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode));
+
+ apply();
+}
+
+/**
+ *
+ * natural-language event parser (create modal header input)
+ */
+function initNaturalEventParser(root = document) {
+ const monthMap = {
+ jan: 0, january: 0,
+ feb: 1, february: 1,
+ mar: 2, march: 2,
+ apr: 3, april: 3,
+ may: 4,
+ jun: 5, june: 5,
+ jul: 6, july: 6,
+ aug: 7, august: 7,
+ sep: 8, sept: 8, september: 8,
+ oct: 9, october: 9,
+ nov: 10, november: 10,
+ dec: 11, december: 11,
+ };
+
+ const pad = (value) => String(value).padStart(2, '0');
+
+ const toLocalDateInputValue = (date) => (
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+ );
+
+ const toLocalDatetimeInputValue = (date) => (
+ `${toLocalDateInputValue(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
+ );
+
+ const readDateInputValue = (value) => {
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
+ const [year, month, day] = value.split('-').map((n) => parseInt(n, 10));
+ if ([year, month, day].some((n) => Number.isNaN(n))) return null;
+ return new Date(year, month - 1, day, 0, 0, 0, 0);
+ };
+
+ const readDatetimeInputValue = (value) => {
+ if (!value || !value.includes('T')) return null;
+ const [datePart, timePart] = value.split('T');
+ if (!datePart || !timePart) return null;
+ const [year, month, day] = datePart.split('-').map((n) => parseInt(n, 10));
+ const [hours, minutes] = timePart.split(':').map((n) => parseInt(n, 10));
+ if ([year, month, day, hours, minutes].some((n) => Number.isNaN(n))) return null;
+ return new Date(year, month - 1, day, hours, minutes, 0, 0);
+ };
+
+ const formatDayLong = (date) => (
+ date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
+ );
+
+ const formatTimeShort = (date) => (
+ date
+ .toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: date.getMinutes() === 0 ? undefined : '2-digit',
+ })
+ .toLowerCase()
+ .replace(/\./g, '')
+ .replace(/\s+/g, '')
+ );
+
+ const formatDateSummary = (startDate, endDate, allDay) => {
+ if (!startDate) return '';
+
+ const startDay = formatDayLong(startDate);
+
+ if (allDay) {
+ if (endDate && endDate.toDateString() !== startDate.toDateString()) {
+ return `${startDay} to ${formatDayLong(endDate)}, all day`;
+ }
+ return `${startDay}, all day`;
+ }
+
+ const startTime = formatTimeShort(startDate);
+ if (!endDate) {
+ return `${startDay}, ${startTime}`;
+ }
+
+ const endTime = formatTimeShort(endDate);
+ if (endDate.toDateString() === startDate.toDateString()) {
+ return `${startDay}, ${startTime} to ${endTime}`;
+ }
+
+ return `${startDay}, ${startTime} to ${formatDayLong(endDate)}, ${endTime}`;
+ };
+
+ const parseDurationMinutes = (text) => {
+ const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
+ if (!match) return 60;
+
+ const count = parseInt(match[1], 10);
+ const unit = (match[2] || '').toLowerCase();
+ if (Number.isNaN(count) || count <= 0) return 60;
+
+ if (unit.startsWith('hour') || unit.startsWith('hr')) {
+ return count * 60;
+ }
+
+ return count;
+ };
+
+ const parseDateToken = (text, now) => {
+ const lower = text.toLowerCase();
+
+ const todayMatch = lower.match(/\btoday\b/);
+ if (todayMatch) {
+ const date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ return { date, index: todayMatch.index ?? 0 };
+ }
+
+ const tomorrowMatch = lower.match(/\btomorrow\b/);
+ if (tomorrowMatch) {
+ const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
+ return { date, index: tomorrowMatch.index ?? 0 };
+ }
+
+ const monthMatch = text.match(/\b(?:on\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\b/);
+ if (monthMatch) {
+ const monthToken = (monthMatch[1] || '').toLowerCase();
+ const month = monthMap[monthToken];
+ const day = parseInt(monthMatch[2], 10);
+ const explicitYear = monthMatch[3] ? parseInt(monthMatch[3], 10) : null;
+
+ if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
+ const thisYear = now.getFullYear();
+ let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0);
+ if (!explicitYear) {
+ const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ if (date < today) {
+ date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
+ }
+ }
+ return { date, index: monthMatch.index ?? 0 };
+ }
+ }
+
+ const numericMatch = text.match(/\b(?:on\s+)?(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
+ if (numericMatch) {
+ const month = parseInt(numericMatch[1], 10) - 1;
+ const day = parseInt(numericMatch[2], 10);
+ const yearToken = numericMatch[3];
+ const thisYear = now.getFullYear();
+ let year = thisYear;
+
+ if (yearToken) {
+ year = parseInt(yearToken, 10);
+ if (year < 100) year += 2000;
+ }
+
+ if (!Number.isNaN(month) && !Number.isNaN(day)) {
+ let date = new Date(year, month, day, 0, 0, 0, 0);
+ if (!yearToken) {
+ const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ if (date < today) {
+ date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
+ }
+ }
+ return { date, index: numericMatch.index ?? 0 };
+ }
+ }
+
+ return null;
+ };
+
+ const parseTimeToken = (text) => {
+ const lowered = text.toLowerCase();
+ const noonIndex = lowered.indexOf(' at noon');
+ if (noonIndex !== -1) {
+ return { hours: 12, minutes: 0, index: noonIndex };
+ }
+
+ const midnightIndex = lowered.indexOf(' at midnight');
+ if (midnightIndex !== -1) {
+ return { hours: 0, minutes: 0, index: midnightIndex };
+ }
+
+ const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i);
+ if (!match) return null;
+
+ let hours = parseInt(match[1], 10);
+ const minutes = parseInt(match[2] || '0', 10);
+ const meridiem = (match[3] || '').toLowerCase();
+
+ if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
+
+ if (meridiem) {
+ if (hours === 12 && meridiem === 'am') hours = 0;
+ if (hours < 12 && meridiem === 'pm') hours += 12;
+ } else if (hours > 23) {
+ return null;
+ }
+
+ return {
+ hours,
+ minutes,
+ index: match.index ?? 0,
+ };
+ };
+
+ const parseRelativeToken = (text, now) => {
+ const addOffset = (days, minutes) => {
+ const date = new Date(now.getTime());
+ date.setMinutes(date.getMinutes() + (days * 24 * 60) + minutes);
+ return date;
+ };
+
+ let match = text.match(/\bin\s+(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\b/i);
+ if (match) {
+ const days = parseInt(match[1], 10);
+ const minutes = parseInt(match[2] || '0', 10);
+ if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
+ return {
+ date: addOffset(days, minutes),
+ index: match.index ?? 0,
+ };
+ }
+ }
+
+ match = text.match(/\b(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\s+from\s+now\b/i);
+ if (match) {
+ const days = parseInt(match[1], 10);
+ const minutes = parseInt(match[2] || '0', 10);
+ if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
+ return {
+ date: addOffset(days, minutes),
+ index: match.index ?? 0,
+ };
+ }
+ }
+
+ match = text.match(/\bin\s+(\d+)\s+minutes?\b/i);
+ if (match) {
+ const minutes = parseInt(match[1], 10);
+ if (!Number.isNaN(minutes)) {
+ return {
+ date: addOffset(0, minutes),
+ index: match.index ?? 0,
+ };
+ }
+ }
+
+ match = text.match(/\b(\d+)\s+minutes?\s+from\s+now\b/i);
+ if (match) {
+ const minutes = parseInt(match[1], 10);
+ if (!Number.isNaN(minutes)) {
+ return {
+ date: addOffset(0, minutes),
+ index: match.index ?? 0,
+ };
+ }
+ }
+
+ return null;
+ };
+
+ const weekdayCodeMap = {
+ su: 'SU', sun: 'SU', sunday: 'SU', sundays: 'SU',
+ mo: 'MO', mon: 'MO', monday: 'MO', mondays: 'MO',
+ tu: 'TU', tue: 'TU', tues: 'TU', tuesday: 'TU', tuesdays: 'TU',
+ we: 'WE', wed: 'WE', wednesday: 'WE', wednesdays: 'WE',
+ th: 'TH', thu: 'TH', thur: 'TH', thurs: 'TH', thursday: 'TH', thursdays: 'TH',
+ fr: 'FR', fri: 'FR', friday: 'FR', fridays: 'FR',
+ sa: 'SA', sat: 'SA', saturday: 'SA', saturdays: 'SA',
+ };
+
+ const weekdayNameMap = {
+ SU: 'Sunday',
+ MO: 'Monday',
+ TU: 'Tuesday',
+ WE: 'Wednesday',
+ TH: 'Thursday',
+ FR: 'Friday',
+ SA: 'Saturday',
+ };
+
+ const weekdayCodeFromDate = (date) => ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][date.getDay()];
+
+ const parseRecurrenceToken = (text) => {
+ const repeatMatch = text.match(/\brepeats?\b([\s\S]*)$/i);
+ if (!repeatMatch) return null;
+
+ const tail = (repeatMatch[1] || '').trim().toLowerCase();
+ const recurrence = {
+ frequency: null,
+ interval: 1,
+ weekdays: [],
+ index: repeatMatch.index ?? 0,
+ };
+
+ const everyNumbered = tail.match(/\bevery\s+(\d+)\s*(day|week|month|year)s?\b/i);
+ if (everyNumbered) {
+ const count = parseInt(everyNumbered[1], 10);
+ const unit = everyNumbered[2].toLowerCase();
+ if (!Number.isNaN(count) && count > 0) {
+ recurrence.interval = count;
+ }
+ recurrence.frequency = {
+ day: 'daily',
+ week: 'weekly',
+ month: 'monthly',
+ year: 'yearly',
+ }[unit] || null;
+ } else if (/\b(daily|every day)\b/i.test(tail)) {
+ recurrence.frequency = 'daily';
+ } else if (/\b(weekly|every week)\b/i.test(tail)) {
+ recurrence.frequency = 'weekly';
+ } else if (/\b(monthly|every month)\b/i.test(tail)) {
+ recurrence.frequency = 'monthly';
+ } else if (/\b(yearly|every year)\b/i.test(tail)) {
+ recurrence.frequency = 'yearly';
+ }
+
+ if (!recurrence.frequency) {
+ return null;
+ }
+
+ if (recurrence.frequency === 'weekly') {
+ const onMatch = tail.match(/\bon\s+(.+)$/i);
+ if (onMatch) {
+ const weekdays = [];
+ const tokens = onMatch[1]
+ .replace(/[,/]/g, ' ')
+ .replace(/\band\b/g, ' ')
+ .split(/\s+/)
+ .map((token) => token.trim().toLowerCase().replace(/[^a-z]/g, ''))
+ .filter(Boolean);
+
+ tokens.forEach((token) => {
+ const code = weekdayCodeMap[token];
+ if (code && !weekdays.includes(code)) {
+ weekdays.push(code);
+ }
+ });
+
+ recurrence.weekdays = weekdays;
+ }
+ }
+
+ return recurrence;
+ };
+
+ const parseLocationToken = (text) => {
+ const lower = text.toLowerCase();
+ const index = lower.lastIndexOf(' at ');
+ if (index === -1) return null;
+
+ const candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, '');
+ if (!candidate) return null;
+ if (/^\d{1,2}(?::\d{2})?\s*(am|pm)?\b/i.test(candidate)) return null;
+ if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null;
+
+ return candidate;
+ };
+
+ const parseTitleToken = (text, dateToken, timeToken, relativeToken, recurrenceToken, locationToken) => {
+ const boundaries = [];
+ if (dateToken) boundaries.push(dateToken.index);
+ if (timeToken) boundaries.push(timeToken.index);
+ if (relativeToken) boundaries.push(relativeToken.index);
+ if (recurrenceToken) boundaries.push(recurrenceToken.index);
+
+ const lower = text.toLowerCase();
+ const durationIndex = lower.indexOf(' for ');
+ if (durationIndex !== -1) boundaries.push(durationIndex);
+
+ if (locationToken) {
+ const locationIndex = lower.lastIndexOf(' at ');
+ if (locationIndex !== -1) boundaries.push(locationIndex);
+ }
+
+ if (!boundaries.length) {
+ return text.trim();
+ }
+
+ const end = Math.min(...boundaries);
+ return text.slice(0, end).trim().replace(/[,:;.\-]+$/, '').trim();
+ };
+
+ const parseNaturalEventText = (text, baseDate) => {
+ const trimmed = (text || '').trim();
+ if (!trimmed) return null;
+
+ const relativeToken = parseRelativeToken(trimmed, baseDate);
+ const recurrenceToken = parseRecurrenceToken(trimmed);
+ const dateToken = parseDateToken(trimmed, baseDate);
+ const timeToken = parseTimeToken(trimmed);
+ const durationMinutes = parseDurationMinutes(trimmed);
+ const location = parseLocationToken(trimmed);
+ const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, recurrenceToken, location);
+ const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
+
+ return {
+ title,
+ location,
+ dateToken,
+ timeToken,
+ relativeToken,
+ recurrenceToken,
+ durationMinutes,
+ allDay,
+ };
+ };
+
+ const findFormFromNaturalInput = (input) => {
+ const dialog = input.closest('dialog');
+ if (dialog) {
+ return dialog.querySelector('#event-form');
+ }
+
+ const modal = input.closest(SELECTORS.modalContent);
+ if (modal) {
+ return modal.querySelector('#event-form');
+ }
+
+ return document.querySelector('#event-form');
+ };
+
+ const enableNaturalCollapsedMode = (input) => {
+ const dialog = input.closest('dialog');
+ const modal = dialog?.querySelector(SELECTORS.modalContent);
+ if (!dialog || !modal || modal.classList.contains('natural-expanded')) return;
+
+ modal.classList.add('natural-collapsed');
+ renderModalAside(dialog, [], true);
+ };
+
+ const clearRecurrenceInputs = (form) => {
+ const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
+ const intervalInput = form.querySelector('input[name="repeat_interval"]');
+ const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
+ const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
+ const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
+
+ if (frequencyInput) {
+ frequencyInput.value = '';
+ frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ if (intervalInput) {
+ intervalInput.value = '1';
+ }
+ weekdayInputs.forEach((input) => {
+ input.checked = false;
+ });
+ monthlyModeInputs.forEach((input) => {
+ input.checked = input.value === 'days';
+ });
+ monthDayInputs.forEach((input) => {
+ input.checked = false;
+ });
+ };
+
+ const applyRecurrenceInputs = (form, recurrenceToken, anchorDate) => {
+ const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
+ const intervalInput = form.querySelector('input[name="repeat_interval"]');
+ const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
+ const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
+ const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
+
+ if (!frequencyInput || !recurrenceToken?.frequency) {
+ return;
+ }
+
+ frequencyInput.value = recurrenceToken.frequency;
+ frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
+
+ if (intervalInput) {
+ intervalInput.value = String(recurrenceToken.interval || 1);
+ }
+
+ if (recurrenceToken.frequency === 'weekly') {
+ let weekdays = recurrenceToken.weekdays || [];
+ if (!weekdays.length && anchorDate) {
+ weekdays = [weekdayCodeFromDate(anchorDate)];
+ }
+ weekdayInputs.forEach((input) => {
+ input.checked = weekdays.includes(input.value);
+ });
+ } else {
+ weekdayInputs.forEach((input) => {
+ input.checked = false;
+ });
+ }
+
+ if (recurrenceToken.frequency === 'monthly') {
+ monthlyModeInputs.forEach((input) => {
+ input.checked = input.value === 'days';
+ });
+
+ const dayOfMonth = anchorDate ? anchorDate.getDate() : null;
+ monthDayInputs.forEach((input) => {
+ input.checked = dayOfMonth ? Number(input.value) === dayOfMonth : false;
+ });
+ } else {
+ monthDayInputs.forEach((input) => {
+ input.checked = false;
+ });
+ }
+ };
+
+ const recurrenceSummary = (recurrenceToken, anchorDate) => {
+ if (!recurrenceToken?.frequency) return '';
+
+ const interval = recurrenceToken.interval || 1;
+
+ if (recurrenceToken.frequency === 'daily') {
+ return interval === 1 ? 'Daily' : `Every ${interval} days`;
+ }
+
+ if (recurrenceToken.frequency === 'weekly') {
+ const days = (recurrenceToken.weekdays || []).length
+ ? recurrenceToken.weekdays
+ : (anchorDate ? [weekdayCodeFromDate(anchorDate)] : []);
+ const dayLabels = days.map((code) => weekdayNameMap[code] || code);
+ const head = interval === 1 ? 'Every week' : `Every ${interval} weeks`;
+ return dayLabels.length ? `${head} on ${dayLabels.join(' and ')}` : head;
+ }
+
+ if (recurrenceToken.frequency === 'monthly') {
+ return interval === 1 ? 'Monthly' : `Every ${interval} months`;
+ }
+
+ if (recurrenceToken.frequency === 'yearly') {
+ return interval === 1 ? 'Yearly' : `Every ${interval} years`;
+ }
+
+ return '';
+ };
+
+ const applyParsedData = (input) => {
+ const dialog = input.closest('dialog');
+ const modal = dialog?.querySelector(SELECTORS.modalContent);
+ const form = findFormFromNaturalInput(input);
+ if (!form) return;
+
+ const titleInput = form.querySelector(SELECTORS.eventTitleInput);
+ const locationInput = form.querySelector(SELECTORS.eventLocationInput);
+ const startInput = form.querySelector(SELECTORS.eventStartInput);
+ const endInput = form.querySelector(SELECTORS.eventEndInput);
+ const allDayToggleHost = form.querySelector(SELECTORS.eventAllDayToggle);
+ const allDayToggle = allDayToggleHost?.matches('input[type="checkbox"]')
+ ? allDayToggleHost
+ : allDayToggleHost?.querySelector('input[type="checkbox"]');
+
+ if (!titleInput || !startInput || !endInput) return;
+
+ const parsed = parseNaturalEventText(input.value, new Date());
+ if (!parsed) {
+ if (modal?.classList.contains('natural-collapsed')) {
+ renderModalAside(dialog, [], true);
+ }
+ return;
+ }
+
+ if (parsed.title) {
+ titleInput.value = parsed.title;
+ }
+
+ if (parsed.location && locationInput) {
+ locationInput.value = parsed.location;
+ }
+
+ const existingStart = startInput.type === 'date'
+ ? readDateInputValue(startInput.value)
+ : readDatetimeInputValue(startInput.value);
+ const baseDate = parsed.relativeToken?.date
+ ? new Date(parsed.relativeToken.date.getTime())
+ : parsed.dateToken?.date
+ ? new Date(parsed.dateToken.date.getTime())
+ : (existingStart ? new Date(existingStart.getTime()) : new Date());
+
+ if (parsed.allDay) {
+ if (allDayToggle && !allDayToggle.checked) {
+ allDayToggle.checked = true;
+ allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ const dateOnly = toLocalDateInputValue(baseDate);
+ startInput.value = dateOnly;
+ endInput.value = dateOnly;
+ } else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) {
+ if (allDayToggle?.checked) {
+ allDayToggle.checked = false;
+ allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
+ const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
+ baseDate.setHours(hours, minutes, 0, 0);
+
+ const end = new Date(baseDate.getTime());
+ end.setMinutes(end.getMinutes() + parsed.durationMinutes);
+
+ startInput.value = toLocalDatetimeInputValue(baseDate);
+ endInput.value = toLocalDatetimeInputValue(end);
+ }
+
+ if (parsed.recurrenceToken) {
+ applyRecurrenceInputs(form, parsed.recurrenceToken, baseDate);
+ input.dataset.nlRecurrenceApplied = '1';
+ } else if (input.dataset.nlRecurrenceApplied === '1') {
+ clearRecurrenceInputs(form);
+ input.dataset.nlRecurrenceApplied = '0';
+ }
+
+ const summaryItems = [];
+ if (parsed.title) {
+ summaryItems.push({ label: 'Title', value: parsed.title });
+ }
+ if (parsed.location) {
+ summaryItems.push({ label: 'Location', value: parsed.location });
+ }
+ if (parsed.dateToken || parsed.timeToken || parsed.relativeToken || parsed.allDay) {
+ const summaryStart = startInput.type === 'date'
+ ? readDateInputValue(startInput.value)
+ : readDatetimeInputValue(startInput.value);
+ const summaryEnd = endInput.type === 'date'
+ ? readDateInputValue(endInput.value)
+ : readDatetimeInputValue(endInput.value);
+ const allDayState = Boolean(allDayToggle?.checked);
+ const summaryDate = formatDateSummary(summaryStart, summaryEnd, allDayState);
+
+ if (summaryDate) {
+ summaryItems.push({ label: 'Date', value: summaryDate });
+ }
+ }
+ if (parsed.recurrenceToken) {
+ const repeat = recurrenceSummary(parsed.recurrenceToken, baseDate);
+ if (repeat) {
+ summaryItems.push({ label: 'Repeat', value: repeat });
+ }
+ }
+
+ if (modal?.classList.contains('natural-collapsed')) {
+ renderModalAside(dialog, summaryItems, true);
+ }
+ };
+
+ if (!document.__naturalEventDelegated) {
+ document.__naturalEventDelegated = true;
+
+ document.addEventListener('input', (event) => {
+ const input = event.target?.closest?.(SELECTORS.naturalEventInput);
+ if (!input) return;
+ applyParsedData(input);
+ });
+
+ document.addEventListener('change', (event) => {
+ const input = event.target?.closest?.(SELECTORS.naturalEventInput);
+ if (!input) return;
+ applyParsedData(input);
+ });
+
+ document.addEventListener('click', (event) => {
+ const button = event.target?.closest?.(SELECTORS.modalExpand);
+ if (!button) return;
+
+ const dialog = button.closest('dialog');
+ const modal = dialog?.querySelector(SELECTORS.modalContent);
+ if (!dialog || !modal) return;
+
+ modal.classList.remove('natural-collapsed');
+ modal.classList.add('natural-expanded');
+ clearModalAside(dialog);
+ });
+ }
+
+ root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
+ enableNaturalCollapsedMode(input);
+
+ if (input.value?.trim()) {
+ applyParsedData(input);
+ }
+ });
+}
+
+/**
+ *
+ * modal behaviors (backdrop close + url restore)
+ */
+function initModalHandlers(root = document) {
+ const dialog = root.querySelector(SELECTORS.modalDialog);
+ if (!dialog || dialog.__modalWired) return;
+ dialog.__modalWired = true;
+
+ dialog.addEventListener('click', (event) => {
+ if (event.target === dialog) {
+ dialog.close();
+ }
+ });
+
+ dialog.addEventListener('close', () => {
+ const modal = dialog.querySelector(SELECTORS.modalContent);
+ if (!modal) return;
+
+ const isEvent = modal.querySelector('[data-modal-kind="event"]');
+ const prevUrl = modal.dataset.prevUrl;
+ modal.classList.remove('natural-collapsed', 'natural-expanded');
+ modal.innerHTML = '';
+ clearModalRootClass(modal);
+ clearModalAside(dialog);
+
+ if (isEvent && prevUrl) {
+ history.replaceState({}, '', prevUrl);
+ }
+ });
+}
+
+/**
+ * tabs (simple modal panels)
+ */
+function initTabs(root = document) {
+ root.querySelectorAll(SELECTORS.tabsRoot).forEach((tabs) => {
+ if (tabs.__tabsWired) return;
+ tabs.__tabsWired = true;
+
+ const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton));
+ const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel));
+ if (!tabEls.length || !panels.length) return;
+
+ const getPanelForTab = (tab, index) => {
+ const controls = tab.getAttribute('aria-controls');
+
+ if (controls) {
+ const panelById = tabs.querySelector(`[role="tabpanel"]#${controls}`);
+ if (panelById) return panelById;
+ }
+
+ return panels[index] || null;
+ };
+
+ const panelByTab = new Map();
+ tabEls.forEach((tab, index) => {
+ const panel = getPanelForTab(tab, index);
+ if (panel) {
+ panelByTab.set(tab, panel);
+ }
+
+ // is not focusable by default.
+ if (!tab.hasAttribute('tabindex')) {
+ tab.setAttribute('tabindex', '-1');
+ }
+ });
+
+ const activate = (activeTab, moveFocus = false) => {
+ tabEls.forEach((tab) => {
+ const isActive = tab === activeTab;
+ tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
+ tab.setAttribute('tabindex', isActive ? '0' : '-1');
+ });
+
+ panels.forEach((panel) => {
+ panel.hidden = true;
+ });
+
+ const panel = panelByTab.get(activeTab);
+ if (panel) {
+ panel.hidden = false;
+ }
+
+ if (moveFocus && typeof activeTab.focus === 'function') {
+ activeTab.focus();
+ }
+ };
+
+ tabs.addEventListener('click', (event) => {
+ const tab = event.target.closest(SELECTORS.tabButton);
+ if (!tab || !tabs.contains(tab)) return;
+
+ event.preventDefault();
+ activate(tab, false);
+ });
+
+ tabs.addEventListener('keydown', (event) => {
+ const currentTab = event.target.closest(SELECTORS.tabButton);
+ if (!currentTab || !tabs.contains(currentTab)) return;
+
+ const currentIndex = tabEls.indexOf(currentTab);
+ if (currentIndex === -1) return;
+
+ let nextIndex = null;
+ const horizontal = ['ArrowLeft', 'ArrowRight'];
+ const vertical = ['ArrowUp', 'ArrowDown'];
+
+ if (event.key === 'Home') nextIndex = 0;
+ if (event.key === 'End') nextIndex = tabEls.length - 1;
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ nextIndex = (currentIndex - 1 + tabEls.length) % tabEls.length;
+ }
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ nextIndex = (currentIndex + 1) % tabEls.length;
+ }
+
+ const orientation = tabs.querySelector('[role="tablist"]')?.getAttribute('aria-orientation') || 'horizontal';
+ if (orientation === 'vertical' && horizontal.includes(event.key)) return;
+ if (orientation !== 'vertical' && vertical.includes(event.key)) return;
+
+ if (nextIndex === null) return;
+
+ event.preventDefault();
+ activate(tabEls[nextIndex], true);
+ });
+
+ const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || tabEls[0];
+ if (current) activate(current, false);
+ });
+}
+
+
+/**
+ *
+ * attendees form controls (contact lookup + email fallback)
+ */
+function initAttendeeControls(root = document) {
+ const normalizeEmail = (value) => String(value || '').trim().toLowerCase();
+ const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value));
+
+ const formatDisplay = (name, email) => {
+ const n = String(name || '').trim();
+ const e = normalizeEmail(email);
+ if (!e) return '';
+ return n ? `${n} <${e}>` : e;
+ };
+
+ root.querySelectorAll(SELECTORS.attendeesRoot).forEach((container) => {
+ if (container.__attendeesWired) return;
+ container.__attendeesWired = true;
+
+ const list = container.querySelector(SELECTORS.attendeesList);
+ const template = container.querySelector(SELECTORS.attendeeTemplate);
+ const lookup = container.querySelector(SELECTORS.attendeeLookup);
+ const suggestions = container.querySelector(SELECTORS.attendeeSuggestions);
+ const addManual = container.querySelector(SELECTORS.attendeeAddManual);
+ if (!list || !template || !lookup) return;
+
+ const nextIndex = () => {
+ const current = parseInt(container.dataset.nextIndex || '0', 10);
+ const safe = Number.isNaN(current) ? 0 : current;
+ container.dataset.nextIndex = String(safe + 1);
+ return safe;
+ };
+
+ const updateRole = (row) => {
+ const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
+ const roleInput = row.querySelector(SELECTORS.attendeeRole);
+ if (!optionalInput || !roleInput) return;
+ roleInput.value = optionalInput.checked ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT';
+ };
+
+ const wireRow = (row) => {
+ if (!row || row.__attendeeRowWired) return;
+ row.__attendeeRowWired = true;
+
+ const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
+ if (optionalInput) {
+ optionalInput.addEventListener('change', () => updateRole(row));
+ }
+
+ updateRole(row);
+ };
+
+ const hasEmail = (email) => {
+ const normalized = normalizeEmail(email);
+ if (!normalized) return true;
+
+ return Array.from(list.querySelectorAll(`${SELECTORS.attendeeRow} ${SELECTORS.attendeeEmail}`))
+ .some((input) => normalizeEmail(input.value) === normalized);
+ };
+
+ const createRow = () => {
+ const index = nextIndex();
+ const html = template.innerHTML.replaceAll('__INDEX__', String(index)).trim();
+ if (!html) return null;
+
+ const fragment = document.createElement('div');
+ fragment.innerHTML = html;
+ return fragment.firstElementChild;
+ };
+
+ const addAttendee = ({ email, name = '', attendeeUri = '', verified = false }) => {
+ const normalizedEmail = normalizeEmail(email);
+ if (!isValidEmail(normalizedEmail)) return false;
+ if (hasEmail(normalizedEmail)) return false;
+
+ const row = createRow();
+ if (!row) return false;
+
+ const uriInput = row.querySelector(SELECTORS.attendeeUri);
+ const emailInput = row.querySelector(SELECTORS.attendeeEmail);
+ const nameInput = row.querySelector(SELECTORS.attendeeName);
+ const display = row.querySelector(SELECTORS.attendeeDisplay);
+ const verifiedEl = row.querySelector(SELECTORS.attendeeVerified);
+
+ if (uriInput) uriInput.value = attendeeUri || `mailto:${normalizedEmail}`;
+ if (emailInput) emailInput.value = normalizedEmail;
+ if (nameInput) nameInput.value = String(name || '').trim();
+ if (display) display.textContent = formatDisplay(name, normalizedEmail);
+ if (verifiedEl) {
+ verifiedEl.classList.toggle('hidden', !verified);
+ }
+
+ list.appendChild(row);
+ wireRow(row);
+ return true;
+ };
+
+ const addFromLookupIfEmail = () => {
+ const raw = lookup.value;
+ if (!isValidEmail(raw)) return false;
+
+ const ok = addAttendee({
+ email: raw,
+ attendeeUri: `mailto:${normalizeEmail(raw)}`,
+ verified: false,
+ });
+
+ if (ok) {
+ lookup.value = '';
+ if (suggestions) suggestions.innerHTML = '';
+ }
+
+ return ok;
+ };
+
+ container.addEventListener('click', (event) => {
+ const removeButton = event.target.closest(SELECTORS.attendeeRemove);
+ if (removeButton && container.contains(removeButton)) {
+ event.preventDefault();
+ const row = removeButton.closest(SELECTORS.attendeeRow);
+ row?.remove();
+ return;
+ }
+
+ const pickButton = event.target.closest(SELECTORS.attendeePick);
+ if (pickButton && container.contains(pickButton)) {
+ event.preventDefault();
+ const email = pickButton.dataset.attendeeEmail || '';
+ const name = pickButton.dataset.attendeeName || '';
+ const attendeeUri = pickButton.dataset.attendeeUri || '';
+ const verified = pickButton.dataset.attendeeVerified === '1';
+
+ const ok = addAttendee({ email, name, attendeeUri, verified });
+ if (ok) {
+ lookup.value = '';
+ if (suggestions) suggestions.innerHTML = '';
+ }
+ return;
+ }
+
+ if (addManual && event.target.closest(SELECTORS.attendeeAddManual)) {
+ event.preventDefault();
+ addFromLookupIfEmail();
+ }
+ });
+
+ lookup.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter') return;
+ event.preventDefault();
+ addFromLookupIfEmail();
+ });
+
+ list.querySelectorAll(SELECTORS.attendeeRow).forEach(wireRow);
+ });
+}
+
+/**
+ *
+ * calendar sidebar expand toggle
+ */
+
+document.addEventListener('click', (event) => {
+ const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
+ if (!toggle) return;
+
+ event.preventDefault();
+
+ const main = toggle.closest('main');
+ if (!main) return;
+
+ const isExpanded = main.classList.toggle('expanded');
+ toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false');
+});
+
+/**
+ *
+ * color picker component
+ * native + hex + random palette)
+ */
+
+/**
+ *
+ * calendar picker (custom dropdown with color chip)
+ */
+function initCalendarPickers(root = document) {
+ root.querySelectorAll(SELECTORS.calendarPicker).forEach((picker) => {
+ if (picker.__calendarPickerWired) return;
+ picker.__calendarPickerWired = true;
+
+ const input = picker.querySelector(SELECTORS.calendarPickerInput);
+ const toggle = picker.querySelector(SELECTORS.calendarPickerToggle);
+ const menu = picker.querySelector(SELECTORS.calendarPickerMenu);
+ const label = picker.querySelector(SELECTORS.calendarPickerLabel);
+ const colorDot = picker.querySelector(SELECTORS.calendarPickerColorDot);
+
+ if (!input || !toggle || !menu || !label || !colorDot) return;
+
+ const close = () => {
+ menu.classList.add('hidden');
+ toggle.setAttribute('aria-expanded', 'false');
+ };
+
+ const open = () => {
+ menu.classList.remove('hidden');
+ toggle.setAttribute('aria-expanded', 'true');
+ };
+
+ toggle.addEventListener('click', (event) => {
+ event.preventDefault();
+ if (menu.classList.contains('hidden')) open();
+ else close();
+ });
+
+ picker.addEventListener('click', (event) => {
+ const option = event.target.closest(SELECTORS.calendarPickerOption);
+ if (!option || !picker.contains(option)) return;
+
+ event.preventDefault();
+
+ const uri = option.dataset.calendarPickerUri || '';
+ const name = option.dataset.calendarPickerName || '';
+ const color = option.dataset.calendarPickerColor || '#64748b';
+
+ input.value = uri;
+ label.textContent = name;
+ colorDot.style.backgroundColor = color;
+ close();
+ });
+
+ document.addEventListener('click', (event) => {
+ if (!picker.contains(event.target)) {
+ close();
+ }
+ });
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ close();
+ }
+ });
+ });
+}
+
+
+export function initEventModalGlobals() {
+ if (document.__eventModalGlobalsWired) return;
+ document.__eventModalGlobalsWired = true;
+
+ // modal htmx tracking
+ document.addEventListener('htmx:beforeSwap', (evt) => {
+ const target = evt.detail?.target || evt.target;
+ if (target && target.id === 'modal') {
+ target.dataset.prevUrl = window.location.href;
+ syncModalRootClass(target);
+ }
+ });
+
+ // close event modal on back/forward navigation
+ window.addEventListener('popstate', () => {
+ if (!document.querySelector('article#calendar')) return;
+
+ const dialog = document.querySelector('dialog');
+ if (!dialog?.open) return;
+
+ const modal = dialog.querySelector('#modal');
+ if (!modal?.querySelector('[data-modal-kind="event"]')) return;
+
+ dialog.close();
+ });
+}
+
+export function initEventModalUI(root = document) {
+ initCalendarPickers(root);
+ initNaturalEventParser(root);
+ initEventAllDayToggles(root);
+ initRecurrenceControls(root);
+ initAttendeeControls(root);
+ initModalHandlers(root);
+ initTabs(root);
+}
+
+export function handleEventModalAfterSwap(target) {
+ if (target && target.id === 'modal') {
+ syncModalRootClass(target);
+ target.closest('dialog')?.showModal();
+ }
+}
+