WIP: February 2026 event improvements and calendar refactor #1
@ -212,7 +212,7 @@
|
||||
|
||||
/* all day bar */
|
||||
ol.day {
|
||||
@apply sticky top-42 grid col-span-2 bg-white border-b border-primary z-10 overflow-x-hidden;
|
||||
@apply sticky top-40 grid col-span-2 bg-white border-b border-primary z-10 overflow-x-hidden;
|
||||
box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0,0,0,0.15);
|
||||
padding: 0.25rem 0 0.2rem 6rem;
|
||||
|
||||
@ -556,6 +556,9 @@
|
||||
hgroup {
|
||||
@apply top-22;
|
||||
}
|
||||
ol.day {
|
||||
@apply top-42;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (height <= 50rem)
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
.close-modal {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* modal uses a <dialog> as the backdrop and grid, with #modal and #modal-aside on top
|
||||
*/
|
||||
dialog {
|
||||
@apply grid fixed inset-0 m-0 p-0 pointer-events-none;
|
||||
@apply place-items-center bg-transparent opacity-0 invisible;
|
||||
@apply w-full h-full max-w-none max-h-none overflow-clip;
|
||||
background-color: rgba(26, 26, 26, 0.75);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
/*(grid-template-rows: minmax(20dvh, 2rem) 1fr; */
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: auto;
|
||||
transition:
|
||||
@ -17,6 +15,7 @@ dialog {
|
||||
visibility 150ms cubic-bezier(0,0,.2,1);
|
||||
z-index: 100;
|
||||
|
||||
/* primary modal container */
|
||||
#modal {
|
||||
@apply relative rounded-xl bg-white border-gray-200 p-0;
|
||||
@apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden;
|
||||
@ -26,15 +25,18 @@ dialog {
|
||||
transition: translate 150ms cubic-bezier(0,0,.2,1);
|
||||
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* close button */
|
||||
> .close-modal {
|
||||
@apply block absolute top-4 right-4 z-3;
|
||||
}
|
||||
|
||||
> .content {
|
||||
/* content wrapper and content defaults */
|
||||
> .modal-content {
|
||||
@apply grid w-full h-full overflow-hidden;
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
/* set the grid based on which elements the content section has */
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
&:has(header):has(footer) {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
@ -48,20 +50,6 @@ dialog {
|
||||
/* modal header */
|
||||
header {
|
||||
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2 pr-16;
|
||||
|
||||
&.input {
|
||||
@apply border-b border-gray-400;
|
||||
|
||||
input {
|
||||
@apply w-full -ml-2 border-0 rounded-sm shadow-none;
|
||||
}
|
||||
|
||||
/* if there are panels, move it down */
|
||||
+ section.modal-body .tabs,
|
||||
+ section.modal-body .panels {
|
||||
@apply pt-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* main content wrapper */
|
||||
@ -78,10 +66,8 @@ dialog {
|
||||
@apply flex flex-col gap-4;
|
||||
|
||||
/* paneled modals get different behavior */
|
||||
&.settings {
|
||||
&:has(.tab-panels) {
|
||||
@apply flex-1 min-h-0 gap-0;
|
||||
}
|
||||
&.settings:has(.tab-panels) {
|
||||
@apply flex-1 min-h-0 gap-0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,20 +84,116 @@ dialog {
|
||||
}
|
||||
}
|
||||
|
||||
&.wide {
|
||||
/* wider version */
|
||||
&.modal--wide {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
&.square {
|
||||
/* forced height on variable content modals */
|
||||
&.modal--square {
|
||||
block-size: clamp(32rem, 72dvh, 54rem);
|
||||
max-block-size: calc(100dvh - 5rem);
|
||||
}
|
||||
|
||||
/* event form modal with a natural language parser and enhanced interactions */
|
||||
&.modal--event
|
||||
{
|
||||
header.input {
|
||||
@apply border-b border-gray-400;
|
||||
|
||||
input {
|
||||
@apply w-full -ml-2 border-0 rounded-sm shadow-none;
|
||||
}
|
||||
|
||||
/* if there are panels, move it down */
|
||||
+ section.modal-body .tabs,
|
||||
+ section.modal-body .panels {
|
||||
@apply pt-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* when the event-create natural language parser is collapsed */
|
||||
&.natural-collapsed {
|
||||
@apply rounded-full;
|
||||
block-size: auto;
|
||||
|
||||
header.input {
|
||||
@apply border-none;
|
||||
|
||||
input {
|
||||
@apply rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
> .modal-content {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
> .modal-content > section.modal-body,
|
||||
> .modal-content > footer {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* extra container over the backdrop below the modal */
|
||||
#modal-aside {
|
||||
@apply w-11/12 max-w-3xl justify-self-center px-4 h-0;
|
||||
@apply flex flex-row justify-between items-start gap-2 translate-y-4;
|
||||
@apply opacity-0 invisible;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
translate 350ms cubic-bezier(0,0,.2,1),
|
||||
opacity 150ms cubic-bezier(0,0,.2,1),
|
||||
visibility 150ms cubic-bezier(0,0,.2,1);
|
||||
|
||||
&.is-visible {
|
||||
@apply opacity-100 visible h-auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-aside-list {
|
||||
@apply list-none m-0 pt-3 flex flex-wrap justify-start gap-2;
|
||||
|
||||
li {
|
||||
@apply text-white rounded-full px-3 py-1 text-sm;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-aside-expand {
|
||||
@apply whitespace-nowrap bg-transparent border-none mt-1 text-sm underline;
|
||||
@apply text-white/90 hover:text-white cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* reposition #modal and #aside when the #aside is used */
|
||||
&:has(#modal-aside) {
|
||||
grid-template-rows: 1fr 0;
|
||||
}
|
||||
&:has(#modal-aside.is-visible) {
|
||||
grid-template-rows: 40dvh auto;
|
||||
|
||||
#modal {
|
||||
@apply self-end;
|
||||
}
|
||||
|
||||
#modal-aside {
|
||||
@apply self-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* hide backdrop before the modal is open */
|
||||
&::backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* open interactions */
|
||||
&[open] {
|
||||
@apply opacity-100 visible;
|
||||
pointer-events: inherit;
|
||||
@ -120,6 +202,10 @@ dialog {
|
||||
@apply translate-y-0;
|
||||
}
|
||||
|
||||
#modal-aside.is-visible {
|
||||
@apply translate-y-0;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
@ -25,6 +25,9 @@ const SELECTORS = {
|
||||
modalDialog: 'dialog',
|
||||
modalContent: '#modal',
|
||||
modalClassSource: '[data-modal-class]',
|
||||
modalAssist: '#modal-aside',
|
||||
modalAssistList: '[data-modal-aside-list]',
|
||||
modalExpand: '[data-modal-expand]',
|
||||
tabsRoot: '[data-tabs]',
|
||||
tabButton: '[role=\"tab\"]',
|
||||
tabPanel: '[role=\"tabpanel\"]',
|
||||
@ -71,6 +74,38 @@ function clearModalRootClass(modal) {
|
||||
modal.dataset.appliedClass = '';
|
||||
}
|
||||
|
||||
function renderModalAssist(dialog, items = [], show = false) {
|
||||
if (!dialog) return;
|
||||
|
||||
const assist = dialog.querySelector(SELECTORS.modalAssist);
|
||||
if (!assist) return;
|
||||
|
||||
const list = assist.querySelector(SELECTORS.modalAssistList);
|
||||
if (list) {
|
||||
list.innerHTML = '';
|
||||
|
||||
items.forEach(({ label, value }) => {
|
||||
const li = document.createElement('li');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = `${label}:`;
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = ` ${value}`;
|
||||
|
||||
li.appendChild(strong);
|
||||
li.appendChild(text);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
assist.classList.toggle('is-visible', show);
|
||||
assist.setAttribute('aria-hidden', show ? 'false' : 'true');
|
||||
}
|
||||
|
||||
function clearModalAssist(dialog) {
|
||||
renderModalAssist(dialog, [], false);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* htmx/global
|
||||
@ -174,7 +209,10 @@ window.addEventListener('popstate', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* event form all-day toggle
|
||||
*
|
||||
* converts the datetime-local inputs into regular date inputs, maintaining the date
|
||||
*/
|
||||
function initEventAllDayToggles(root = document) {
|
||||
const toDate = (value) => {
|
||||
@ -243,6 +281,7 @@ function initEventAllDayToggles(root = document) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* recurrence preset selector
|
||||
*/
|
||||
function initRecurrenceControls(root = document) {
|
||||
@ -313,6 +352,7 @@ function initRecurrenceControls(root = document) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* natural-language event parser (create modal header input)
|
||||
*/
|
||||
function initNaturalEventParser(root = document) {
|
||||
@ -332,12 +372,23 @@ function initNaturalEventParser(root = document) {
|
||||
};
|
||||
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
const toLocalInputValue = (date) => (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
|
||||
const toLocalDateInputValue = (date) => (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
);
|
||||
|
||||
const readLocalInputValue = (value) => {
|
||||
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;
|
||||
@ -347,6 +398,20 @@ function initNaturalEventParser(root = document) {
|
||||
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||
};
|
||||
|
||||
const formatInputValue = (value, type) => {
|
||||
if (!value) return '';
|
||||
|
||||
if (type === 'date') {
|
||||
const date = readDateInputValue(value);
|
||||
if (!date) return value;
|
||||
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const datetime = readDatetimeInputValue(value);
|
||||
if (!datetime) return value;
|
||||
return datetime.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const parseDurationMinutes = (text) => {
|
||||
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
|
||||
if (!match) return 60;
|
||||
@ -386,8 +451,7 @@ function initNaturalEventParser(root = document) {
|
||||
|
||||
if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
|
||||
const thisYear = now.getFullYear();
|
||||
let year = explicitYear || thisYear;
|
||||
let date = new Date(year, month, day, 0, 0, 0, 0);
|
||||
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) {
|
||||
@ -505,6 +569,7 @@ function initNaturalEventParser(root = document) {
|
||||
const durationMinutes = parseDurationMinutes(trimmed);
|
||||
const location = parseLocationToken(trimmed);
|
||||
const title = parseTitleToken(trimmed, dateToken, timeToken, location);
|
||||
const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
|
||||
|
||||
return {
|
||||
title,
|
||||
@ -512,6 +577,7 @@ function initNaturalEventParser(root = document) {
|
||||
dateToken,
|
||||
timeToken,
|
||||
durationMinutes,
|
||||
allDay,
|
||||
};
|
||||
};
|
||||
|
||||
@ -529,7 +595,18 @@ function initNaturalEventParser(root = document) {
|
||||
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');
|
||||
renderModalAssist(dialog, [], true);
|
||||
};
|
||||
|
||||
const applyParsedData = (input) => {
|
||||
const dialog = input.closest('dialog');
|
||||
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
||||
const form = findFormFromNaturalInput(input);
|
||||
if (!form) return;
|
||||
|
||||
@ -545,7 +622,12 @@ function initNaturalEventParser(root = document) {
|
||||
if (!titleInput || !startInput || !endInput) return;
|
||||
|
||||
const parsed = parseNaturalEventText(input.value, new Date());
|
||||
if (!parsed) return;
|
||||
if (!parsed) {
|
||||
if (modal?.classList.contains('natural-collapsed')) {
|
||||
renderModalAssist(dialog, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.title) {
|
||||
titleInput.value = parsed.title;
|
||||
@ -555,27 +637,59 @@ function initNaturalEventParser(root = document) {
|
||||
locationInput.value = parsed.location;
|
||||
}
|
||||
|
||||
if (!parsed.dateToken && !parsed.timeToken) return;
|
||||
|
||||
if (allDayToggle?.checked) {
|
||||
allDayToggle.checked = false;
|
||||
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
const existingStart = readLocalInputValue(startInput.value);
|
||||
const base = parsed.dateToken?.date
|
||||
const existingStart = startInput.type === 'date'
|
||||
? readDateInputValue(startInput.value)
|
||||
: readDatetimeInputValue(startInput.value);
|
||||
const baseDate = parsed.dateToken?.date
|
||||
? new Date(parsed.dateToken.date.getTime())
|
||||
: (existingStart ? new Date(existingStart.getTime()) : new Date());
|
||||
|
||||
const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
|
||||
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
|
||||
base.setHours(hours, minutes, 0, 0);
|
||||
if (parsed.allDay) {
|
||||
if (allDayToggle && !allDayToggle.checked) {
|
||||
allDayToggle.checked = true;
|
||||
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
const end = new Date(base.getTime());
|
||||
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
||||
const dateOnly = toLocalDateInputValue(baseDate);
|
||||
startInput.value = dateOnly;
|
||||
endInput.value = dateOnly;
|
||||
} else if (parsed.dateToken || parsed.timeToken) {
|
||||
if (allDayToggle?.checked) {
|
||||
allDayToggle.checked = false;
|
||||
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
startInput.value = toLocalInputValue(base);
|
||||
endInput.value = toLocalInputValue(end);
|
||||
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);
|
||||
}
|
||||
|
||||
const summaryItems = [];
|
||||
if (parsed.title) {
|
||||
summaryItems.push({ label: 'Title', value: parsed.title });
|
||||
}
|
||||
if (parsed.location) {
|
||||
summaryItems.push({ label: 'Location', value: parsed.location });
|
||||
}
|
||||
if (parsed.allDay) {
|
||||
summaryItems.push({ label: 'All day', value: 'Yes' });
|
||||
}
|
||||
if ((parsed.dateToken || parsed.timeToken || parsed.allDay) && startInput.value) {
|
||||
summaryItems.push({ label: 'Start', value: formatInputValue(startInput.value, startInput.type) });
|
||||
}
|
||||
if ((parsed.dateToken || parsed.timeToken || parsed.allDay) && endInput.value) {
|
||||
summaryItems.push({ label: 'End', value: formatInputValue(endInput.value, endInput.type) });
|
||||
}
|
||||
|
||||
if (modal?.classList.contains('natural-collapsed')) {
|
||||
renderModalAssist(dialog, summaryItems, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!document.__naturalEventDelegated) {
|
||||
@ -592,9 +706,24 @@ function initNaturalEventParser(root = document) {
|
||||
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');
|
||||
clearModalAssist(dialog);
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
||||
enableNaturalCollapsedMode(input);
|
||||
|
||||
if (input.value?.trim()) {
|
||||
applyParsedData(input);
|
||||
}
|
||||
@ -622,8 +751,10 @@ function initModalHandlers(root = document) {
|
||||
|
||||
const isEvent = modal.querySelector('[data-modal-kind="event"]');
|
||||
const prevUrl = modal.dataset.prevUrl;
|
||||
modal.classList.remove('natural-collapsed', 'natural-expanded');
|
||||
modal.innerHTML = '';
|
||||
clearModalRootClass(modal);
|
||||
clearModalAssist(dialog);
|
||||
|
||||
if (isEvent && prevUrl) {
|
||||
history.replaceState({}, '', prevUrl);
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
@if(filled($modalClass))
|
||||
data-modal-class="{{ trim((string) $modalClass) }}"
|
||||
@endif
|
||||
{{ $attributes->class('content') }}
|
||||
{{ $attributes->class('modal-content') }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@ -3,4 +3,10 @@
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
<div id="modal-aside" aria-hidden="true">
|
||||
<ul class="modal-aside-list" data-modal-aside-list></ul>
|
||||
<button type="button" class="modal-aside-expand" data-modal-expand>
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<x-modal.content modal-class="wide square">
|
||||
<x-modal.content modal-class="modal--wide modal--square modal--event">
|
||||
<x-modal.title class="input">
|
||||
@if ($event->exists)
|
||||
<h2>{{ __('Edit event details') }}</h2>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user