diff --git a/resources/css/app.css b/resources/css/app.css index ba2b19b..9b901e4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -16,6 +16,7 @@ @import './lib/input.css'; @import './lib/mini.css'; @import './lib/modal.css'; +@import './lib/tabs.css'; @import './lib/toast.css'; /** plugins */ diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css index 6ee82b1..43e00ed 100644 --- a/resources/css/etc/layout.css +++ b/resources/css/etc/layout.css @@ -187,10 +187,14 @@ main { @apply overflow-y-auto; grid-template-rows: 5rem auto; container: content / inline-size; - transition: - margin 250ms ease-in-out, - width 250ms ease-in-out, - padding 250ms ease-in-out; + + /* specific animation sets */ + calendar { + transition: + margin 250ms ease-in-out, + width 250ms ease-in-out, + padding 250ms ease-in-out; + } /* main content title and actions */ header { diff --git a/resources/css/lib/input.css b/resources/css/lib/input.css index 945c1b8..8fc2a6f 100644 --- a/resources/css/lib/input.css +++ b/resources/css/lib/input.css @@ -1,9 +1,12 @@ /** * default text inputs */ +input[type="date"], +input[type="datetime-local"], input[type="email"], input[type="password"], input[type="text"], +input[type="time"], input[type="url"], input[type="search"], select, @@ -99,10 +102,6 @@ form { &.modal { @apply mt-0; - - .input-row { - @apply !mt-0; - } } } } diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index c027c99..6340556 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -19,8 +19,7 @@ dialog { #modal { @apply relative rounded-xl bg-white border-gray-200 p-0; - @apply flex flex-col items-start col-start-1 translate-y-4; - @apply overscroll-contain overflow-y-auto; + @apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden; max-height: calc(100dvh - 5rem); width: 91.666667%; max-width: 36rem; @@ -32,30 +31,53 @@ dialog { } > .content { - @apply w-full; + @apply grid w-full h-full overflow-hidden; + grid-template-rows: 1fr; + + /* set the grid based on which elements the content section has */ + &:has(header):has(footer) { + grid-template-rows: auto minmax(0, 1fr) auto; + } + &:has(header):not(:has(footer)) { + grid-template-rows: auto minmax(0, 1fr); + } + &:has(footer):not(:has(header)) { + grid-template-rows: minmax(0, 1fr) auto; + } /* modal header */ header { - @apply sticky top-0 bg-white flex items-center px-6 h-20 z-2; + @apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2; h2 { @apply pr-12; } } - /* main content pane */ - section { + /* main content wrapper */ + section.modal-body { @apply flex flex-col px-6 pb-8; + + &.no-margin { + @apply p-0; + } } /* standard form with 1rem gap between rows */ form { @apply flex flex-col gap-4; + + /* paneled modals get different behavior */ + &.settings { + &:has(.tab-panels) { + @apply flex-1 min-h-0 gap-0; + } + } } /* footer */ footer { - @apply sticky bottom-0 bg-white px-6 py-4 border-t-md border-gray-400 flex justify-between; + @apply sticky bottom-0 bg-white px-6 py-4 border-t-md border-gray-300 flex justify-between; } /* event modal with a map */ @@ -65,6 +87,15 @@ dialog { } } } + + &.wide { + max-width: 48rem; + } + + &.square { + block-size: clamp(32rem, 72dvh, 54rem); + max-block-size: calc(100dvh - 5rem); + } } &::backdrop { @@ -84,3 +115,33 @@ dialog { } } } + +/** + * tabbed content panels in a modal + */ +.tab-panels { + @apply grid items-start min-h-0 h-full gap-4; + grid-template-columns: 12rem minmax(0, 1fr); + + .tabs { + @apply sticky top-0 self-start; + } + + .tabs--vertical { + @apply pl-4; + + li { + @apply rounded-r-none; + + &[aria-selected="true"] { + button { + @apply bg-cyan-200; + } + } + } + } + + .panels { + @apply h-full min-h-0 pt-2 pr-6 pb-6 pl-1 overflow-y-auto; + } +} diff --git a/resources/css/lib/tabs.css b/resources/css/lib/tabs.css new file mode 100644 index 0000000..e4b9a94 --- /dev/null +++ b/resources/css/lib/tabs.css @@ -0,0 +1,17 @@ +.tabs { + @apply flex flex-row gap-0 items-center justify-start p-2 gap-1; + + &.tabs--vertical { + @apply flex-col items-start; + } + + li { + @apply flex flex-col w-full rounded-md; + + button { + &:hover { + @apply bg-cyan-100; + } + } + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 409dd6d..2dcd02e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -21,6 +21,7 @@ const SELECTORS = { monthlyWeekday: '[data-monthly-weekday]', modalDialog: 'dialog', modalContent: '#modal', + modalClassSource: '[data-modal-class]', tabsRoot: '[data-tabs]', tabButton: '[role=\"tab\"]', tabPanel: '[role=\"tabpanel\"]', @@ -30,6 +31,43 @@ const SELECTORS = { monthDayMoreWrap: '.more-events', }; +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 = ''; +} + /** * * htmx/global @@ -53,6 +91,7 @@ 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); } }); @@ -288,6 +327,7 @@ function initModalHandlers(root = document) { const isEvent = modal.querySelector('[data-modal-kind="event"]'); const prevUrl = modal.dataset.prevUrl; modal.innerHTML = ''; + clearModalRootClass(modal); if (isEvent && prevUrl) { history.replaceState({}, '', prevUrl); @@ -303,33 +343,95 @@ function initTabs(root = document) { if (tabs.__tabsWired) return; tabs.__tabsWired = true; - const buttons = tabs.querySelectorAll(SELECTORS.tabButton); - const panels = tabs.querySelectorAll(SELECTORS.tabPanel); - if (!buttons.length || !panels.length) return; + const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton)); + const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel)); + if (!tabEls.length || !panels.length) return; - const activate = (button) => { - buttons.forEach((btn) => { - const isActive = btn === button; - btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); + 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); + } + + //