From 87047cf4c371c868a9b60cd718fa4961e682d6e0 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Wed, 18 Feb 2026 13:57:06 -0500 Subject: [PATCH] Modal and tab panel updates --- resources/css/app.css | 1 + resources/css/etc/layout.css | 12 +- resources/css/lib/input.css | 7 +- resources/css/lib/modal.css | 75 +++++++++- resources/css/lib/tabs.css | 17 +++ resources/js/app.js | 139 +++++++++++++++--- .../views/components/modal/body.blade.php | 2 +- .../views/components/modal/content.blade.php | 11 +- .../views/event/partials/form-modal.blade.php | 4 +- resources/views/event/partials/form.blade.php | 40 ++--- 10 files changed, 254 insertions(+), 54 deletions(-) create mode 100644 resources/css/lib/tabs.css 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); + } + + //
  • 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) => { - const id = button.getAttribute('aria-controls'); - panel.hidden = panel.id !== id; + panel.hidden = true; }); + + const panel = panelByTab.get(activeTab); + if (panel) { + panel.hidden = false; + } + + if (moveFocus && typeof activeTab.focus === 'function') { + activeTab.focus(); + } }; - buttons.forEach((btn) => { - btn.addEventListener('click', (event) => { - event.preventDefault(); - activate(btn); - }); + tabs.addEventListener('click', (event) => { + const tab = event.target.closest(SELECTORS.tabButton); + if (!tab || !tabs.contains(tab)) return; + + event.preventDefault(); + activate(tab, false); }); - const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || buttons[0]; - if (current) { - activate(current); - } + 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); }); } @@ -634,6 +736,7 @@ document.addEventListener('DOMContentLoaded', initUI); document.addEventListener('htmx:afterSwap', (e) => { const target = e.detail?.target || e.target; if (target && target.id === 'modal') { + syncModalRootClass(target); target.closest('dialog')?.showModal(); } diff --git a/resources/views/components/modal/body.blade.php b/resources/views/components/modal/body.blade.php index f57add4..a1971e3 100644 --- a/resources/views/components/modal/body.blade.php +++ b/resources/views/components/modal/body.blade.php @@ -1,3 +1,3 @@ -
    class(['flex flex-col px-8 pb-6']) }}> +
    class(['modal-body']) }}> {{ $slot }}
    diff --git a/resources/views/components/modal/content.blade.php b/resources/views/components/modal/content.blade.php index bfcf818..a368888 100644 --- a/resources/views/components/modal/content.blade.php +++ b/resources/views/components/modal/content.blade.php @@ -1,8 +1,17 @@ +@props([ + 'modalClass' => null, +]) +
    -
    class('content') }}> +
    class('content') }} +> {{ $slot }}
    diff --git a/resources/views/event/partials/form-modal.blade.php b/resources/views/event/partials/form-modal.blade.php index 779c515..cd7db5c 100644 --- a/resources/views/event/partials/form-modal.blade.php +++ b/resources/views/event/partials/form-modal.blade.php @@ -1,8 +1,8 @@ - +

    {{ $event->exists ? __('Edit event details') : __('Create a new event') }}

    - + @include('event.partials.form', [ 'calendar' => $calendar, 'event' => $event, diff --git a/resources/views/event/partials/form.blade.php b/resources/views/event/partials/form.blade.php index af981cd..0606ef1 100644 --- a/resources/views/event/partials/form.blade.php +++ b/resources/views/event/partials/form.blade.php @@ -11,9 +11,30 @@ @method('PUT') @endif -
    -
    +
    + + + + + +
    + {{-- Title --}}
    @@ -236,21 +257,6 @@
    - -
    - - - -
    {{-- Submit --}}