Compare commits
2 Commits
b579530fad
...
e024639209
| Author | SHA1 | Date | |
|---|---|---|---|
| e024639209 | |||
| 87047cf4c3 |
@ -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 */
|
||||
|
||||
@ -187,10 +187,14 @@ main {
|
||||
@apply overflow-y-auto;
|
||||
grid-template-rows: 5rem auto;
|
||||
container: content / inline-size;
|
||||
|
||||
/* 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 {
|
||||
|
||||
@ -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,
|
||||
@ -16,6 +19,10 @@ textarea {
|
||||
&[disabled] {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
&.input--lg {
|
||||
@apply h-13 text-lg rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -99,10 +106,6 @@ form {
|
||||
|
||||
&.modal {
|
||||
@apply mt-0;
|
||||
|
||||
.input-row {
|
||||
@apply !mt-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,63 @@ 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 pr-16;
|
||||
|
||||
h2 {
|
||||
@apply pr-12;
|
||||
&.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 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 +97,15 @@ dialog {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.wide {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
&.square {
|
||||
block-size: clamp(32rem, 72dvh, 54rem);
|
||||
max-block-size: calc(100dvh - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
@ -84,3 +125,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ const SELECTORS = {
|
||||
eventAllDayToggle: '[data-all-day-toggle]',
|
||||
eventStartInput: '[data-event-start]',
|
||||
eventEndInput: '[data-event-end]',
|
||||
eventTitleInput: 'input[name="title"]',
|
||||
eventLocationInput: 'input[name="location"]',
|
||||
naturalEventInput: '[data-natural-event-input]',
|
||||
recurrenceFrequency: '[data-recurrence-frequency]',
|
||||
recurrenceInterval: '[data-recurrence-interval]',
|
||||
recurrenceUnit: '[data-recurrence-unit]',
|
||||
@ -21,6 +24,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 +34,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 +94,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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -145,11 +187,15 @@ function initEventAllDayToggles(root = document) {
|
||||
return `${date}T${time}`;
|
||||
};
|
||||
|
||||
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => {
|
||||
if (toggle.__allDayWired) return;
|
||||
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggleHost) => {
|
||||
const toggle = toggleHost.matches('input[type="checkbox"]')
|
||||
? toggleHost
|
||||
: toggleHost.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (!toggle || toggle.__allDayWired) return;
|
||||
toggle.__allDayWired = true;
|
||||
|
||||
const form = toggle.closest('form');
|
||||
const form = toggle.closest('form') || toggleHost.closest('form');
|
||||
if (!form) return;
|
||||
|
||||
const start = form.querySelector(SELECTORS.eventStartInput);
|
||||
@ -266,6 +312,295 @@ function initRecurrenceControls(root = document) {
|
||||
apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* natural-language event parser (create modal header input)
|
||||
*/
|
||||
function initNaturalEventParser(root = document) {
|
||||
const monthMap = {
|
||||
jan: 0, january: 0,
|
||||
feb: 1, february: 1,
|
||||
mar: 2, march: 2,
|
||||
apr: 3, april: 3,
|
||||
may: 4,
|
||||
jun: 5, june: 5,
|
||||
jul: 6, july: 6,
|
||||
aug: 7, august: 7,
|
||||
sep: 8, sept: 8, september: 8,
|
||||
oct: 9, october: 9,
|
||||
nov: 10, november: 10,
|
||||
dec: 11, december: 11,
|
||||
};
|
||||
|
||||
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 readLocalInputValue = (value) => {
|
||||
if (!value || !value.includes('T')) return null;
|
||||
const [datePart, timePart] = value.split('T');
|
||||
if (!datePart || !timePart) return null;
|
||||
const [year, month, day] = datePart.split('-').map((n) => parseInt(n, 10));
|
||||
const [hours, minutes] = timePart.split(':').map((n) => parseInt(n, 10));
|
||||
if ([year, month, day, hours, minutes].some((n) => Number.isNaN(n))) return null;
|
||||
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||
};
|
||||
|
||||
const parseDurationMinutes = (text) => {
|
||||
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
|
||||
if (!match) return 60;
|
||||
|
||||
const count = parseInt(match[1], 10);
|
||||
const unit = (match[2] || '').toLowerCase();
|
||||
if (Number.isNaN(count) || count <= 0) return 60;
|
||||
|
||||
if (unit.startsWith('hour') || unit.startsWith('hr')) {
|
||||
return count * 60;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const parseDateToken = (text, now) => {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
const todayMatch = lower.match(/\btoday\b/);
|
||||
if (todayMatch) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
return { date, index: todayMatch.index ?? 0 };
|
||||
}
|
||||
|
||||
const tomorrowMatch = lower.match(/\btomorrow\b/);
|
||||
if (tomorrowMatch) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
|
||||
return { date, index: tomorrowMatch.index ?? 0 };
|
||||
}
|
||||
|
||||
const monthMatch = text.match(/\b(?:on\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\b/);
|
||||
if (monthMatch) {
|
||||
const monthToken = (monthMatch[1] || '').toLowerCase();
|
||||
const month = monthMap[monthToken];
|
||||
const day = parseInt(monthMatch[2], 10);
|
||||
const explicitYear = monthMatch[3] ? parseInt(monthMatch[3], 10) : null;
|
||||
|
||||
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);
|
||||
if (!explicitYear) {
|
||||
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
if (date < today) {
|
||||
date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
return { date, index: monthMatch.index ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const numericMatch = text.match(/\b(?:on\s+)?(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
|
||||
if (numericMatch) {
|
||||
const month = parseInt(numericMatch[1], 10) - 1;
|
||||
const day = parseInt(numericMatch[2], 10);
|
||||
const yearToken = numericMatch[3];
|
||||
const thisYear = now.getFullYear();
|
||||
let year = thisYear;
|
||||
|
||||
if (yearToken) {
|
||||
year = parseInt(yearToken, 10);
|
||||
if (year < 100) year += 2000;
|
||||
}
|
||||
|
||||
if (!Number.isNaN(month) && !Number.isNaN(day)) {
|
||||
let date = new Date(year, month, day, 0, 0, 0, 0);
|
||||
if (!yearToken) {
|
||||
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
if (date < today) {
|
||||
date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
return { date, index: numericMatch.index ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseTimeToken = (text) => {
|
||||
const lowered = text.toLowerCase();
|
||||
const noonIndex = lowered.indexOf(' at noon');
|
||||
if (noonIndex !== -1) {
|
||||
return { hours: 12, minutes: 0, index: noonIndex };
|
||||
}
|
||||
|
||||
const midnightIndex = lowered.indexOf(' at midnight');
|
||||
if (midnightIndex !== -1) {
|
||||
return { hours: 0, minutes: 0, index: midnightIndex };
|
||||
}
|
||||
|
||||
const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i);
|
||||
if (!match) return null;
|
||||
|
||||
let hours = parseInt(match[1], 10);
|
||||
const minutes = parseInt(match[2] || '0', 10);
|
||||
const meridiem = (match[3] || '').toLowerCase();
|
||||
|
||||
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
|
||||
|
||||
if (meridiem) {
|
||||
if (hours === 12 && meridiem === 'am') hours = 0;
|
||||
if (hours < 12 && meridiem === 'pm') hours += 12;
|
||||
} else if (hours > 23) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hours,
|
||||
minutes,
|
||||
index: match.index ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const parseLocationToken = (text) => {
|
||||
const lower = text.toLowerCase();
|
||||
const index = lower.lastIndexOf(' at ');
|
||||
if (index === -1) return null;
|
||||
|
||||
const candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, '');
|
||||
if (!candidate) return null;
|
||||
if (/^\d{1,2}(?::\d{2})?\s*(am|pm)?\b/i.test(candidate)) return null;
|
||||
if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null;
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const parseTitleToken = (text, dateToken, timeToken, locationToken) => {
|
||||
const boundaries = [];
|
||||
if (dateToken) boundaries.push(dateToken.index);
|
||||
if (timeToken) boundaries.push(timeToken.index);
|
||||
|
||||
const lower = text.toLowerCase();
|
||||
const durationIndex = lower.indexOf(' for ');
|
||||
if (durationIndex !== -1) boundaries.push(durationIndex);
|
||||
|
||||
if (locationToken) {
|
||||
const locationIndex = lower.lastIndexOf(' at ');
|
||||
if (locationIndex !== -1) boundaries.push(locationIndex);
|
||||
}
|
||||
|
||||
if (!boundaries.length) {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const end = Math.min(...boundaries);
|
||||
return text.slice(0, end).trim().replace(/[,:;.\-]+$/, '').trim();
|
||||
};
|
||||
|
||||
const parseNaturalEventText = (text, baseDate) => {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const dateToken = parseDateToken(trimmed, baseDate);
|
||||
const timeToken = parseTimeToken(trimmed);
|
||||
const durationMinutes = parseDurationMinutes(trimmed);
|
||||
const location = parseLocationToken(trimmed);
|
||||
const title = parseTitleToken(trimmed, dateToken, timeToken, location);
|
||||
|
||||
return {
|
||||
title,
|
||||
location,
|
||||
dateToken,
|
||||
timeToken,
|
||||
durationMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
const findFormFromNaturalInput = (input) => {
|
||||
const dialog = input.closest('dialog');
|
||||
if (dialog) {
|
||||
return dialog.querySelector('#event-form');
|
||||
}
|
||||
|
||||
const modal = input.closest(SELECTORS.modalContent);
|
||||
if (modal) {
|
||||
return modal.querySelector('#event-form');
|
||||
}
|
||||
|
||||
return document.querySelector('#event-form');
|
||||
};
|
||||
|
||||
const applyParsedData = (input) => {
|
||||
const form = findFormFromNaturalInput(input);
|
||||
if (!form) return;
|
||||
|
||||
const titleInput = form.querySelector(SELECTORS.eventTitleInput);
|
||||
const locationInput = form.querySelector(SELECTORS.eventLocationInput);
|
||||
const startInput = form.querySelector(SELECTORS.eventStartInput);
|
||||
const endInput = form.querySelector(SELECTORS.eventEndInput);
|
||||
const allDayToggleHost = form.querySelector(SELECTORS.eventAllDayToggle);
|
||||
const allDayToggle = allDayToggleHost?.matches('input[type="checkbox"]')
|
||||
? allDayToggleHost
|
||||
: allDayToggleHost?.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (!titleInput || !startInput || !endInput) return;
|
||||
|
||||
const parsed = parseNaturalEventText(input.value, new Date());
|
||||
if (!parsed) return;
|
||||
|
||||
if (parsed.title) {
|
||||
titleInput.value = parsed.title;
|
||||
}
|
||||
|
||||
if (parsed.location && locationInput) {
|
||||
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
|
||||
? 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);
|
||||
|
||||
const end = new Date(base.getTime());
|
||||
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
||||
|
||||
startInput.value = toLocalInputValue(base);
|
||||
endInput.value = toLocalInputValue(end);
|
||||
};
|
||||
|
||||
if (!document.__naturalEventDelegated) {
|
||||
document.__naturalEventDelegated = true;
|
||||
|
||||
document.addEventListener('input', (event) => {
|
||||
const input = event.target?.closest?.(SELECTORS.naturalEventInput);
|
||||
if (!input) return;
|
||||
applyParsedData(input);
|
||||
});
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const input = event.target?.closest?.(SELECTORS.naturalEventInput);
|
||||
if (!input) return;
|
||||
applyParsedData(input);
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
||||
if (input.value?.trim()) {
|
||||
applyParsedData(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* modal behaviors (backdrop close + url restore)
|
||||
@ -288,6 +623,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 +639,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);
|
||||
}
|
||||
|
||||
// <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) => {
|
||||
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) => {
|
||||
tabs.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest(SELECTORS.tabButton);
|
||||
if (!tab || !tabs.contains(tab)) return;
|
||||
|
||||
event.preventDefault();
|
||||
activate(btn);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -619,6 +1017,7 @@ window.addEventListener('resize', () => {
|
||||
|
||||
function initUI() {
|
||||
initColorPickers();
|
||||
initNaturalEventParser();
|
||||
initEventAllDayToggles();
|
||||
initRecurrenceControls();
|
||||
initModalHandlers();
|
||||
@ -634,10 +1033,12 @@ 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();
|
||||
}
|
||||
|
||||
initColorPickers(e.target);
|
||||
initNaturalEventParser(e.target);
|
||||
initEventAllDayToggles(e.target);
|
||||
initRecurrenceControls(e.target);
|
||||
initModalHandlers(e.target);
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
name="{{ $name }}"
|
||||
value="{{ $value }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
{{ $attributes->merge(['class' => 'text']) }}
|
||||
{{ $attributes->merge(['class' => 'text '.$class]) }}
|
||||
@if($style !== '') style="{{ $style }}" @endif
|
||||
@required($required)
|
||||
@disabled($disabled) />
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
<section {{ $attributes->class(['flex flex-col px-8 pb-6']) }}>
|
||||
<section {{ $attributes->class(['modal-body']) }}>
|
||||
{{ $slot }}
|
||||
</section>
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
@props([
|
||||
'modalClass' => null,
|
||||
])
|
||||
|
||||
<form method="dialog" class="close-modal">
|
||||
<x-button.icon type="submit" label="Close the modal" autofocus>
|
||||
<x-icon-x />
|
||||
</x-button.icon>
|
||||
</form>
|
||||
<div {{ $attributes->class('content') }}>
|
||||
<div
|
||||
@if(filled($modalClass))
|
||||
data-modal-class="{{ trim((string) $modalClass) }}"
|
||||
@endif
|
||||
{{ $attributes->class('content') }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
<x-modal.content>
|
||||
<x-modal.title>
|
||||
<h2>{{ $event->exists ? __('Edit event details') : __('Create a new event') }}</h2>
|
||||
<x-modal.content modal-class="wide square">
|
||||
<x-modal.title class="input">
|
||||
@if ($event->exists)
|
||||
<h2>{{ __('Edit event details') }}</h2>
|
||||
@else
|
||||
<label for="event-natural-input" class="sr-only">Describe event</label>
|
||||
<x-input.text
|
||||
id="event-natural-input"
|
||||
type="text"
|
||||
class="input--lg"
|
||||
placeholder="Start typing to create an event..."
|
||||
data-natural-event-input
|
||||
autofocus
|
||||
/>
|
||||
@endif
|
||||
</x-modal.title>
|
||||
<x-modal.body>
|
||||
<x-modal.body class="no-margin">
|
||||
@include('event.partials.form', [
|
||||
'calendar' => $calendar,
|
||||
'event' => $event,
|
||||
|
||||
@ -11,9 +11,30 @@
|
||||
@method('PUT')
|
||||
@endif
|
||||
|
||||
<div class="event-tabs" data-tabs>
|
||||
<div class="tab-panels">
|
||||
<div class="tab-panels" data-tabs>
|
||||
<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">
|
||||
|
||||
{{-- Title --}}
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
@ -24,7 +45,7 @@
|
||||
type="text"
|
||||
:value="old('title', $event->meta?->title ?? '')"
|
||||
required
|
||||
autofocus
|
||||
:autofocus="$event->exists"
|
||||
/>
|
||||
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
||||
</div>
|
||||
@ -236,21 +257,6 @@
|
||||
</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>
|
||||
|
||||
{{-- Submit --}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user