From 1184673721d17ce64e03092c775f93456d048b27 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Thu, 19 Feb 2026 10:30:43 -0500 Subject: [PATCH] Cleans up event modal styles and modal structure to be a bit simpler and logical --- resources/css/lib/calendar.css | 5 +- resources/css/lib/modal.css | 140 +++++++++++--- resources/js/app.js | 177 +++++++++++++++--- .../views/components/modal/content.blade.php | 2 +- .../views/components/modal/index.blade.php | 6 + .../views/event/partials/form-modal.blade.php | 2 +- 6 files changed, 279 insertions(+), 53 deletions(-) diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index f95356c..7ffdbe1 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -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) diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index 335bac6..f4613ee 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -1,14 +1,12 @@ -.close-modal { - @apply hidden; -} - +/** + * modal uses a 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; } diff --git a/resources/js/app.js b/resources/js/app.js index 01e9834..65fd681 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); diff --git a/resources/views/components/modal/content.blade.php b/resources/views/components/modal/content.blade.php index a368888..6543b7f 100644 --- a/resources/views/components/modal/content.blade.php +++ b/resources/views/components/modal/content.blade.php @@ -11,7 +11,7 @@ @if(filled($modalClass)) data-modal-class="{{ trim((string) $modalClass) }}" @endif - {{ $attributes->class('content') }} + {{ $attributes->class('modal-content') }} > {{ $slot }} diff --git a/resources/views/components/modal/index.blade.php b/resources/views/components/modal/index.blade.php index 4b406f0..d928ab3 100644 --- a/resources/views/components/modal/index.blade.php +++ b/resources/views/components/modal/index.blade.php @@ -3,4 +3,10 @@ hx-target="this" hx-swap="innerHTML"> + diff --git a/resources/views/event/partials/form-modal.blade.php b/resources/views/event/partials/form-modal.blade.php index bf23fec..cdb16df 100644 --- a/resources/views/event/partials/form-modal.blade.php +++ b/resources/views/event/partials/form-modal.blade.php @@ -1,4 +1,4 @@ - + @if ($event->exists)

{{ __('Edit event details') }}