Modal and tab panel updates
This commit is contained in:
parent
b579530fad
commit
87047cf4c3
@ -16,6 +16,7 @@
|
|||||||
@import './lib/input.css';
|
@import './lib/input.css';
|
||||||
@import './lib/mini.css';
|
@import './lib/mini.css';
|
||||||
@import './lib/modal.css';
|
@import './lib/modal.css';
|
||||||
|
@import './lib/tabs.css';
|
||||||
@import './lib/toast.css';
|
@import './lib/toast.css';
|
||||||
|
|
||||||
/** plugins */
|
/** plugins */
|
||||||
|
|||||||
@ -187,10 +187,14 @@ main {
|
|||||||
@apply overflow-y-auto;
|
@apply overflow-y-auto;
|
||||||
grid-template-rows: 5rem auto;
|
grid-template-rows: 5rem auto;
|
||||||
container: content / inline-size;
|
container: content / inline-size;
|
||||||
transition:
|
|
||||||
margin 250ms ease-in-out,
|
/* specific animation sets */
|
||||||
width 250ms ease-in-out,
|
&#calendar {
|
||||||
padding 250ms ease-in-out;
|
transition:
|
||||||
|
margin 250ms ease-in-out,
|
||||||
|
width 250ms ease-in-out,
|
||||||
|
padding 250ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* main content title and actions */
|
/* main content title and actions */
|
||||||
header {
|
header {
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* default text inputs
|
* default text inputs
|
||||||
*/
|
*/
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="time"],
|
||||||
input[type="url"],
|
input[type="url"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
select,
|
select,
|
||||||
@ -99,10 +102,6 @@ form {
|
|||||||
|
|
||||||
&.modal {
|
&.modal {
|
||||||
@apply mt-0;
|
@apply mt-0;
|
||||||
|
|
||||||
.input-row {
|
|
||||||
@apply !mt-0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,7 @@ dialog {
|
|||||||
|
|
||||||
#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;
|
@apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden;
|
||||||
@apply overscroll-contain overflow-y-auto;
|
|
||||||
max-height: calc(100dvh - 5rem);
|
max-height: calc(100dvh - 5rem);
|
||||||
width: 91.666667%;
|
width: 91.666667%;
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
@ -32,30 +31,53 @@ dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
> .content {
|
> .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 */
|
/* modal header */
|
||||||
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 {
|
h2 {
|
||||||
@apply pr-12;
|
@apply pr-12;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* main content pane */
|
/* main content wrapper */
|
||||||
section {
|
section.modal-body {
|
||||||
@apply flex flex-col px-6 pb-8;
|
@apply flex flex-col px-6 pb-8;
|
||||||
|
|
||||||
|
&.no-margin {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* standard form with 1rem gap between rows */
|
/* standard form with 1rem gap between rows */
|
||||||
form {
|
form {
|
||||||
@apply flex flex-col gap-4;
|
@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 */
|
||||||
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 */
|
/* 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 {
|
&::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
resources/css/lib/tabs.css
Normal file
17
resources/css/lib/tabs.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ const SELECTORS = {
|
|||||||
monthlyWeekday: '[data-monthly-weekday]',
|
monthlyWeekday: '[data-monthly-weekday]',
|
||||||
modalDialog: 'dialog',
|
modalDialog: 'dialog',
|
||||||
modalContent: '#modal',
|
modalContent: '#modal',
|
||||||
|
modalClassSource: '[data-modal-class]',
|
||||||
tabsRoot: '[data-tabs]',
|
tabsRoot: '[data-tabs]',
|
||||||
tabButton: '[role=\"tab\"]',
|
tabButton: '[role=\"tab\"]',
|
||||||
tabPanel: '[role=\"tabpanel\"]',
|
tabPanel: '[role=\"tabpanel\"]',
|
||||||
@ -30,6 +31,43 @@ const SELECTORS = {
|
|||||||
monthDayMoreWrap: '.more-events',
|
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
|
* htmx/global
|
||||||
@ -53,6 +91,7 @@ document.addEventListener('htmx:beforeSwap', (evt) => {
|
|||||||
const target = evt.detail?.target || evt.target;
|
const target = evt.detail?.target || evt.target;
|
||||||
if (target && target.id === 'modal') {
|
if (target && target.id === 'modal') {
|
||||||
target.dataset.prevUrl = window.location.href;
|
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 isEvent = modal.querySelector('[data-modal-kind="event"]');
|
||||||
const prevUrl = modal.dataset.prevUrl;
|
const prevUrl = modal.dataset.prevUrl;
|
||||||
modal.innerHTML = '';
|
modal.innerHTML = '';
|
||||||
|
clearModalRootClass(modal);
|
||||||
|
|
||||||
if (isEvent && prevUrl) {
|
if (isEvent && prevUrl) {
|
||||||
history.replaceState({}, '', prevUrl);
|
history.replaceState({}, '', prevUrl);
|
||||||
@ -303,33 +343,95 @@ function initTabs(root = document) {
|
|||||||
if (tabs.__tabsWired) return;
|
if (tabs.__tabsWired) return;
|
||||||
tabs.__tabsWired = true;
|
tabs.__tabsWired = true;
|
||||||
|
|
||||||
const buttons = tabs.querySelectorAll(SELECTORS.tabButton);
|
const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton));
|
||||||
const panels = tabs.querySelectorAll(SELECTORS.tabPanel);
|
const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel));
|
||||||
if (!buttons.length || !panels.length) return;
|
if (!tabEls.length || !panels.length) return;
|
||||||
|
|
||||||
const activate = (button) => {
|
const getPanelForTab = (tab, index) => {
|
||||||
buttons.forEach((btn) => {
|
const controls = tab.getAttribute('aria-controls');
|
||||||
const isActive = btn === button;
|
|
||||||
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// <li role="tab"> 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) => {
|
panels.forEach((panel) => {
|
||||||
const id = button.getAttribute('aria-controls');
|
panel.hidden = true;
|
||||||
panel.hidden = panel.id !== id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const panel = panelByTab.get(activeTab);
|
||||||
|
if (panel) {
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moveFocus && typeof activeTab.focus === 'function') {
|
||||||
|
activeTab.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
buttons.forEach((btn) => {
|
tabs.addEventListener('click', (event) => {
|
||||||
btn.addEventListener('click', (event) => {
|
const tab = event.target.closest(SELECTORS.tabButton);
|
||||||
event.preventDefault();
|
if (!tab || !tabs.contains(tab)) return;
|
||||||
activate(btn);
|
|
||||||
});
|
event.preventDefault();
|
||||||
|
activate(tab, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || buttons[0];
|
tabs.addEventListener('keydown', (event) => {
|
||||||
if (current) {
|
const currentTab = event.target.closest(SELECTORS.tabButton);
|
||||||
activate(current);
|
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) => {
|
document.addEventListener('htmx:afterSwap', (e) => {
|
||||||
const target = e.detail?.target || e.target;
|
const target = e.detail?.target || e.target;
|
||||||
if (target && target.id === 'modal') {
|
if (target && target.id === 'modal') {
|
||||||
|
syncModalRootClass(target);
|
||||||
target.closest('dialog')?.showModal();
|
target.closest('dialog')?.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
<section {{ $attributes->class(['flex flex-col px-8 pb-6']) }}>
|
<section {{ $attributes->class(['modal-body']) }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
|
@props([
|
||||||
|
'modalClass' => null,
|
||||||
|
])
|
||||||
|
|
||||||
<form method="dialog" class="close-modal">
|
<form method="dialog" class="close-modal">
|
||||||
<x-button.icon type="submit" label="Close the modal" autofocus>
|
<x-button.icon type="submit" label="Close the modal" autofocus>
|
||||||
<x-icon-x />
|
<x-icon-x />
|
||||||
</x-button.icon>
|
</x-button.icon>
|
||||||
</form>
|
</form>
|
||||||
<div {{ $attributes->class('content') }}>
|
<div
|
||||||
|
@if(filled($modalClass))
|
||||||
|
data-modal-class="{{ trim((string) $modalClass) }}"
|
||||||
|
@endif
|
||||||
|
{{ $attributes->class('content') }}
|
||||||
|
>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<x-modal.content>
|
<x-modal.content modal-class="wide event-form-modal square">
|
||||||
<x-modal.title>
|
<x-modal.title>
|
||||||
<h2>{{ $event->exists ? __('Edit event details') : __('Create a new event') }}</h2>
|
<h2>{{ $event->exists ? __('Edit event details') : __('Create a new event') }}</h2>
|
||||||
</x-modal.title>
|
</x-modal.title>
|
||||||
<x-modal.body>
|
<x-modal.body class="no-margin">
|
||||||
@include('event.partials.form', [
|
@include('event.partials.form', [
|
||||||
'calendar' => $calendar,
|
'calendar' => $calendar,
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
|
|||||||
@ -11,9 +11,30 @@
|
|||||||
@method('PUT')
|
@method('PUT')
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="event-tabs" data-tabs>
|
<div class="tab-panels" data-tabs>
|
||||||
<div class="tab-panels">
|
<menu class="tabs tabs--vertical" role="tablist" aria-orientation="vertical">
|
||||||
|
<li id="tab-details" role="tab" aria-controls="tab-details" aria-selected="true">
|
||||||
|
<x-button type="button">
|
||||||
|
<x-icon-info-circle width="20" />
|
||||||
|
<span>Details</span>
|
||||||
|
</x-button>
|
||||||
|
</li>
|
||||||
|
<li id="tab-repeat" role="tab" aria-controls="tab-repeat" aria-selected="false">
|
||||||
|
<x-button type="button">
|
||||||
|
<x-icon-repeat width="20" />
|
||||||
|
<span>Repeat</span>
|
||||||
|
</x-button>
|
||||||
|
</li>
|
||||||
|
<li id="tab-invitees" role="tab" aria-controls="tab-invitees" aria-selected="false">
|
||||||
|
<x-button type="button">
|
||||||
|
<x-icon-user-circle width="20" />
|
||||||
|
<span>Invitees</span>
|
||||||
|
</x-button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
<div class="panels">
|
||||||
<div id="tab-details" role="tabpanel" aria-labelledby="tab-btn-details">
|
<div id="tab-details" role="tabpanel" aria-labelledby="tab-btn-details">
|
||||||
|
|
||||||
{{-- Title --}}
|
{{-- Title --}}
|
||||||
<div class="input-row input-row--1">
|
<div class="input-row input-row--1">
|
||||||
<div class="input-cell">
|
<div class="input-cell">
|
||||||
@ -236,21 +257,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-buttons" role="tablist" aria-orientation="vertical">
|
|
||||||
<button type="button" id="tab-btn-details" role="tab" aria-controls="tab-details" aria-selected="true">
|
|
||||||
<x-icon-info-circle width="20" />
|
|
||||||
<span>Details</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="tab-btn-repeat" role="tab" aria-controls="tab-repeat" aria-selected="false">
|
|
||||||
<x-icon-repeat width="20" />
|
|
||||||
<span>Repeat</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="tab-btn-invitees" role="tab" aria-controls="tab-invitees" aria-selected="false">
|
|
||||||
<x-icon-user-circle width="20" />
|
|
||||||
<span>Invitees</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Submit --}}
|
{{-- Submit --}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user