Cleans up event modal styles and modal structure to be a bit simpler and logical

This commit is contained in:
Andrew Gioia 2026-02-19 10:30:43 -05:00
parent e024639209
commit 1184673721
Signed by: andrew
GPG Key ID: FC09694A000800C8
6 changed files with 279 additions and 53 deletions

View File

@ -212,7 +212,7 @@
/* all day bar */ /* all day bar */
ol.day { 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); box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0,0,0,0.15);
padding: 0.25rem 0 0.2rem 6rem; padding: 0.25rem 0 0.2rem 6rem;
@ -556,6 +556,9 @@
hgroup { hgroup {
@apply top-22; @apply top-22;
} }
ol.day {
@apply top-42;
}
} }
} }
@media (height <= 50rem) @media (height <= 50rem)

View File

@ -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 { dialog {
@apply grid fixed inset-0 m-0 p-0 pointer-events-none; @apply grid fixed inset-0 m-0 p-0 pointer-events-none;
@apply place-items-center bg-transparent opacity-0 invisible; @apply place-items-center bg-transparent opacity-0 invisible;
@apply w-full h-full max-w-none max-h-none overflow-clip; @apply w-full h-full max-w-none max-h-none overflow-clip;
background-color: rgba(26, 26, 26, 0.75); background-color: rgba(26, 26, 26, 0.75);
backdrop-filter: blur(0.25rem); backdrop-filter: blur(0.25rem);
/*(grid-template-rows: minmax(20dvh, 2rem) 1fr; */
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-gutter: auto; scrollbar-gutter: auto;
transition: transition:
@ -17,6 +15,7 @@ dialog {
visibility 150ms cubic-bezier(0,0,.2,1); visibility 150ms cubic-bezier(0,0,.2,1);
z-index: 100; z-index: 100;
/* primary modal container */
#modal { #modal {
@apply relative rounded-xl bg-white border-gray-200 p-0; @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; @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); transition: translate 150ms cubic-bezier(0,0,.2,1);
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4); box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
/* close button */
> .close-modal { > .close-modal {
@apply block absolute top-4 right-4 z-3; @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; @apply grid w-full h-full overflow-hidden;
grid-template-rows: 1fr;
/* set the grid based on which elements the content section has */ /* set the grid based on which elements the content section has */
grid-template-rows: 1fr;
&:has(header):has(footer) { &:has(header):has(footer) {
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
} }
@ -48,20 +50,6 @@ dialog {
/* modal header */ /* modal header */
header { header {
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2 pr-16; @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 */ /* main content wrapper */
@ -78,10 +66,8 @@ dialog {
@apply flex flex-col gap-4; @apply flex flex-col gap-4;
/* paneled modals get different behavior */ /* paneled modals get different behavior */
&.settings { &.settings:has(.tab-panels) {
&:has(.tab-panels) { @apply flex-1 min-h-0 gap-0;
@apply flex-1 min-h-0 gap-0;
}
} }
} }
@ -98,20 +84,116 @@ dialog {
} }
} }
&.wide { /* wider version */
&.modal--wide {
max-width: 48rem; max-width: 48rem;
} }
&.square { /* forced height on variable content modals */
&.modal--square {
block-size: clamp(32rem, 72dvh, 54rem); block-size: clamp(32rem, 72dvh, 54rem);
max-block-size: calc(100dvh - 5rem); 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 { &::backdrop {
display: none; display: none;
} }
/* open interactions */
&[open] { &[open] {
@apply opacity-100 visible; @apply opacity-100 visible;
pointer-events: inherit; pointer-events: inherit;
@ -120,6 +202,10 @@ dialog {
@apply translate-y-0; @apply translate-y-0;
} }
#modal-aside.is-visible {
@apply translate-y-0;
}
&::backdrop { &::backdrop {
@apply opacity-100; @apply opacity-100;
} }

View File

@ -25,6 +25,9 @@ const SELECTORS = {
modalDialog: 'dialog', modalDialog: 'dialog',
modalContent: '#modal', modalContent: '#modal',
modalClassSource: '[data-modal-class]', modalClassSource: '[data-modal-class]',
modalAssist: '#modal-aside',
modalAssistList: '[data-modal-aside-list]',
modalExpand: '[data-modal-expand]',
tabsRoot: '[data-tabs]', tabsRoot: '[data-tabs]',
tabButton: '[role=\"tab\"]', tabButton: '[role=\"tab\"]',
tabPanel: '[role=\"tabpanel\"]', tabPanel: '[role=\"tabpanel\"]',
@ -71,6 +74,38 @@ function clearModalRootClass(modal) {
modal.dataset.appliedClass = ''; 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 * htmx/global
@ -174,7 +209,10 @@ window.addEventListener('popstate', () => {
}); });
/** /**
*
* event form all-day toggle * event form all-day toggle
*
* converts the datetime-local inputs into regular date inputs, maintaining the date
*/ */
function initEventAllDayToggles(root = document) { function initEventAllDayToggles(root = document) {
const toDate = (value) => { const toDate = (value) => {
@ -243,6 +281,7 @@ function initEventAllDayToggles(root = document) {
} }
/** /**
*
* recurrence preset selector * recurrence preset selector
*/ */
function initRecurrenceControls(root = document) { function initRecurrenceControls(root = document) {
@ -313,6 +352,7 @@ function initRecurrenceControls(root = document) {
} }
/** /**
*
* natural-language event parser (create modal header input) * natural-language event parser (create modal header input)
*/ */
function initNaturalEventParser(root = document) { function initNaturalEventParser(root = document) {
@ -332,12 +372,23 @@ function initNaturalEventParser(root = document) {
}; };
const pad = (value) => String(value).padStart(2, '0'); const pad = (value) => String(value).padStart(2, '0');
const toLocalInputValue = (date) => (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const toLocalDateInputValue = (date) => (
`T${pad(date.getHours())}:${pad(date.getMinutes())}` `${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; if (!value || !value.includes('T')) return null;
const [datePart, timePart] = value.split('T'); const [datePart, timePart] = value.split('T');
if (!datePart || !timePart) return null; if (!datePart || !timePart) return null;
@ -347,6 +398,20 @@ function initNaturalEventParser(root = document) {
return new Date(year, month - 1, day, hours, minutes, 0, 0); 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 parseDurationMinutes = (text) => {
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i); const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
if (!match) return 60; if (!match) return 60;
@ -386,8 +451,7 @@ function initNaturalEventParser(root = document) {
if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) { if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
const thisYear = now.getFullYear(); const thisYear = now.getFullYear();
let year = explicitYear || thisYear; let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0);
let date = new Date(year, month, day, 0, 0, 0, 0);
if (!explicitYear) { if (!explicitYear) {
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0); const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
if (date < today) { if (date < today) {
@ -505,6 +569,7 @@ function initNaturalEventParser(root = document) {
const durationMinutes = parseDurationMinutes(trimmed); const durationMinutes = parseDurationMinutes(trimmed);
const location = parseLocationToken(trimmed); const location = parseLocationToken(trimmed);
const title = parseTitleToken(trimmed, dateToken, timeToken, location); const title = parseTitleToken(trimmed, dateToken, timeToken, location);
const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
return { return {
title, title,
@ -512,6 +577,7 @@ function initNaturalEventParser(root = document) {
dateToken, dateToken,
timeToken, timeToken,
durationMinutes, durationMinutes,
allDay,
}; };
}; };
@ -529,7 +595,18 @@ function initNaturalEventParser(root = document) {
return document.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');
renderModalAssist(dialog, [], true);
};
const applyParsedData = (input) => { const applyParsedData = (input) => {
const dialog = input.closest('dialog');
const modal = dialog?.querySelector(SELECTORS.modalContent);
const form = findFormFromNaturalInput(input); const form = findFormFromNaturalInput(input);
if (!form) return; if (!form) return;
@ -545,7 +622,12 @@ function initNaturalEventParser(root = document) {
if (!titleInput || !startInput || !endInput) return; if (!titleInput || !startInput || !endInput) return;
const parsed = parseNaturalEventText(input.value, new Date()); 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) { if (parsed.title) {
titleInput.value = parsed.title; titleInput.value = parsed.title;
@ -555,27 +637,59 @@ function initNaturalEventParser(root = document) {
locationInput.value = parsed.location; locationInput.value = parsed.location;
} }
if (!parsed.dateToken && !parsed.timeToken) return; const existingStart = startInput.type === 'date'
? readDateInputValue(startInput.value)
if (allDayToggle?.checked) { : readDatetimeInputValue(startInput.value);
allDayToggle.checked = false; const baseDate = parsed.dateToken?.date
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
}
const existingStart = readLocalInputValue(startInput.value);
const base = parsed.dateToken?.date
? new Date(parsed.dateToken.date.getTime()) ? new Date(parsed.dateToken.date.getTime())
: (existingStart ? new Date(existingStart.getTime()) : new Date()); : (existingStart ? new Date(existingStart.getTime()) : new Date());
const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9); if (parsed.allDay) {
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0); if (allDayToggle && !allDayToggle.checked) {
base.setHours(hours, minutes, 0, 0); allDayToggle.checked = true;
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
}
const end = new Date(base.getTime()); const dateOnly = toLocalDateInputValue(baseDate);
end.setMinutes(end.getMinutes() + parsed.durationMinutes); 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); const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
endInput.value = toLocalInputValue(end); 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) { if (!document.__naturalEventDelegated) {
@ -592,9 +706,24 @@ function initNaturalEventParser(root = document) {
if (!input) return; if (!input) return;
applyParsedData(input); 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) => { root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
enableNaturalCollapsedMode(input);
if (input.value?.trim()) { if (input.value?.trim()) {
applyParsedData(input); applyParsedData(input);
} }
@ -622,8 +751,10 @@ function initModalHandlers(root = document) {
const isEvent = modal.querySelector('[data-modal-kind="event"]'); const isEvent = modal.querySelector('[data-modal-kind="event"]');
const prevUrl = modal.dataset.prevUrl; const prevUrl = modal.dataset.prevUrl;
modal.classList.remove('natural-collapsed', 'natural-expanded');
modal.innerHTML = ''; modal.innerHTML = '';
clearModalRootClass(modal); clearModalRootClass(modal);
clearModalAssist(dialog);
if (isEvent && prevUrl) { if (isEvent && prevUrl) {
history.replaceState({}, '', prevUrl); history.replaceState({}, '', prevUrl);

View File

@ -11,7 +11,7 @@
@if(filled($modalClass)) @if(filled($modalClass))
data-modal-class="{{ trim((string) $modalClass) }}" data-modal-class="{{ trim((string) $modalClass) }}"
@endif @endif
{{ $attributes->class('content') }} {{ $attributes->class('modal-content') }}
> >
{{ $slot }} {{ $slot }}
</div> </div>

View File

@ -3,4 +3,10 @@
hx-target="this" hx-target="this"
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </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> </dialog>

View File

@ -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"> <x-modal.title class="input">
@if ($event->exists) @if ($event->exists)
<h2>{{ __('Edit event details') }}</h2> <h2>{{ __('Edit event details') }}</h2>