diff --git a/calendar.css b/calendar.css index 604ddd1..354c4e8 100644 --- a/calendar.css +++ b/calendar.css @@ -11,10 +11,17 @@ --select: #aaf; --label-bg: #fafbfe; --label-bg-rgb: 250, 251, 254; + + /* Layout */ + --row-h: 2.2em; + --label-w: minmax(4em, 8%); + --cell-w: 1fr; + --cell-h: clamp(4em, 8vh, 8em); + --overlay-w: minmax(3rem, 5%); } /* Prevent text selection in calendar */ -#calendar-viewport, #calendar-content, .week-row, .cell, +#calendar-viewport, #calendar-content, .week-row, .cell, .calendar-header, .week-label, .month-name-label, .calendar-container, .jogwheel-viewport, .jogwheel-content { -webkit-user-select: none; @@ -26,18 +33,18 @@ } /* Month tints (light) */ -.cell.dec { background: hsl(220 20% 95%) } -.cell.jan { background: hsl(220 20% 88%) } -.cell.feb { background: hsl(220 20% 95%) } -.cell.mar { background: hsl(125 60% 88%) } -.cell.apr { background: hsl(125 60% 95%) } -.cell.may { background: hsl(125 60% 88%) } -.cell.jun { background: hsl(45 85% 95%) } -.cell.jul { background: hsl(45 85% 88%) } -.cell.aug { background: hsl(45 85% 95%) } -.cell.sep { background: hsl(18 78% 88%) } -.cell.oct { background: hsl(18 78% 95%) } -.cell.nov { background: hsl(18 78% 88%) } +.dec { background: hsl(220 20% 95%) } +.jan { background: hsl(220 20% 88%) } +.feb { background: hsl(220 20% 95%) } +.mar { background: hsl(125 60% 88%) } +.apr { background: hsl(125 60% 95%) } +.may { background: hsl(125 60% 88%) } +.jun { background: hsl(45 85% 95%) } +.jul { background: hsl(45 85% 88%) } +.aug { background: hsl(45 85% 95%) } +.sep { background: hsl(18 78% 88%) } +.oct { background: hsl(18 78% 95%) } +.nov { background: hsl(18 78% 88%) } /* Color tokens (dark) */ @media (prefers-color-scheme: dark) { @@ -56,29 +63,21 @@ } /* Month tints (dark) */ - .cell.dec { background: hsl(220 20% 22%) } - .cell.jan { background: hsl(220 20% 16%) } - .cell.feb { background: hsl(220 20% 22%) } - .cell.mar { background: hsl(125 40% 18%) } - .cell.apr { background: hsl(125 40% 26%) } - .cell.may { background: hsl(125 40% 18%) } - .cell.jun { background: hsl(45 70% 24%) } - .cell.jul { background: hsl(45 70% 18%) } - .cell.aug { background: hsl(45 70% 24%) } - .cell.sep { background: hsl(18 70% 18%) } - .cell.oct { background: hsl(18 70% 26%) } - .cell.nov { background: hsl(18 70% 18%) } + .dec { background: hsl(220 20% 22%) } + .jan { background: hsl(220 20% 16%) } + .feb { background: hsl(220 20% 22%) } + .mar { background: hsl(125 40% 18%) } + .apr { background: hsl(125 40% 26%) } + .may { background: hsl(125 40% 18%) } + .jun { background: hsl(45 70% 24%) } + .jul { background: hsl(45 70% 18%) } + .aug { background: hsl(45 70% 24%) } + .sep { background: hsl(18 70% 18%) } + .oct { background: hsl(18 70% 26%) } + .nov { background: hsl(18 70% 18%) } } /* Layout & typography */ -:root { - --row-h: 2.2em; - --label-w: minmax(4em, 8%); - --cell-w: 1fr; - --cell-h: clamp(4em, 8vh, 8em); - --overlay-w: minmax(3rem, 5%); -} - * { box-sizing: border-box } body { @@ -113,15 +112,12 @@ header { gap: .75rem; } -.today-date { - cursor: pointer; -} -.today-date::first-line { - color: var(--today); -} +.today-date { cursor: pointer } +.today-date::first-line { color: var(--today) } .today-button:hover { opacity: .8 } -.calendar-header { +/* Header row */ +.calendar-header, #calendar-header { display: grid; grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); border-bottom: .1em solid var(--muted); @@ -129,8 +125,11 @@ header { flex-shrink: 0; width: 100%; } +.calendar-header .dow-label { display:grid; place-items:center; height: var(--cell-h); color: var(--muted) } +.overlay-header-spacer { grid-column: -2 / -1 } -.calendar-container { +/* Main container */ +.calendar-container, #calendar-container { flex: 1; overflow: hidden; position: relative; @@ -138,7 +137,8 @@ header { display: flex; } -.calendar-viewport { +/* Viewports (support id or class) */ +.calendar-viewport, #calendar-viewport { height: 100%; overflow-y: auto; overflow-x: hidden; @@ -146,13 +146,12 @@ header { width: 100%; scrollbar-width: none; } -.calendar-viewport::-webkit-scrollbar { display: none } +.calendar-viewport::-webkit-scrollbar, +#calendar-viewport::-webkit-scrollbar { display: none } -.jogwheel-viewport { +.jogwheel-viewport, #jogwheel-viewport { position: absolute; - top: 0; - right: 0; - bottom: 0; + top: 0; right: 0; bottom: 0; width: var(--overlay-w); overflow-y: auto; overflow-x: hidden; @@ -160,12 +159,13 @@ header { z-index: 20; cursor: ns-resize; } -.jogwheel-viewport::-webkit-scrollbar { display: none } +.jogwheel-viewport::-webkit-scrollbar, +#jogwheel-viewport::-webkit-scrollbar { display: none } -.jogwheel-content { position: relative; width: 100% } - -.calendar-content { position: relative } +.jogwheel-content, #jogwheel-content { position: relative; width: 100% } +.calendar-content, #calendar-content { position: relative } +/* Week row: label + 7-day grid + jogwheel column */ .week-row { display: grid; grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); @@ -176,14 +176,8 @@ header { width: 100%; } -/* Fixed heights for cells and labels */ -.week-row .cell, -.week-row .week-label { height: var(--cell-h) } - -header h1 { margin: 0; font-size: 1rem } - -.dow-label, -.week-label { +/* Label cells */ +.dow-label, .week-label { display: grid; place-items: center; width: 100%; @@ -193,14 +187,38 @@ header h1 { margin: 0; font-size: 1rem } font-size: 1.2em; } -.dow { text-transform: uppercase; } +/* 7-day grid inside each week row */ +.week-row > .days-grid { + grid-column: 2 / span 7; + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: 1fr; + position: relative; + height: 100%; + width: 100%; +} + +/* Overlay sitting above the day cells, same 7-col grid */ +.week-row > .days-grid > .week-overlay { + margin-top: 1.2em; + position: absolute; + inset: 0; + pointer-events: none; + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: 1fr; + z-index: 15; +} + +/* Day cells */ +.dow { text-transform: uppercase } .cell { position: relative; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; - padding: 0.25em; + padding: .25em; overflow: hidden; width: 100%; height: var(--cell-h); @@ -208,110 +226,83 @@ header h1 { margin: 0; font-size: 1rem } cursor: pointer; transition: background-color .15s ease; } - .cell h1 { - position: absolute; - top: 0.25em; - right: 0.25em; + top: .25em; + right: .25em; padding: 0; margin: 0; transition: background-color .15s ease; font-size: 1em; - z-index: 10; } -.cell:hover h1 { text-shadow: 0 0 .2em; } +.cell:hover h1 { text-shadow: 0 0 .2em } -/* Event styles */ +/* Fixed heights for cells and labels */ +.week-row .cell, .week-row .week-label { height: var(--cell-h) } + +/* Event (per-cell, if used) */ .event { - font-size: 0.75em; - padding: 0.1em 0.3em; - margin: 0.1em 0; - border-radius: 0.2em; + font-size: .75em; + padding: .1em .3em; + margin: .1em 0; + border-radius: .2em; color: white; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; - max-width: calc(100% - 0.5em); + max-width: calc(100% - .5em); line-height: 1.2; cursor: pointer; z-index: 5; } +.event:hover { opacity: .8 } -.event:hover { - opacity: 0.8; -} - -/* Spanning event styles */ +/* Spanning events in the overlay (grid-positioned, not absolutely measured) */ .event-span { - font-size: 0.75em; - padding: 0.1em 0.3em; - border-radius: 0.2em; + font-size: .75em; + padding: 0 .5em; + border-radius: .4em; color: white; - font-weight: 500; + font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; - cursor: pointer; - transition: opacity 0.15s ease; - - /* Calculate position and width using CSS variables and custom properties */ - left: calc(var(--label-w) + var(--start-day) * (100% - var(--label-w) - var(--overlay-w)) / 7); - width: calc(var(--span-days) * (100% - var(--label-w) - var(--overlay-w)) / 7); -} - -.event-span:hover { - opacity: 0.8; -} - -.event-more { - font-size: 0.7em; - color: var(--muted); - font-style: italic; - margin-top: 0.1em; + height: 1.2em; + align-self: center; /* vertically center within the overlay row */ + justify-self: stretch; /* stretch across chosen grid columns */ + pointer-events: auto; /* clickable despite overlay having none */ + z-index: 1; } /* Selection styles */ .weekend { color: var(--weekend) } .firstday { color: var(--firstday); text-shadow: 0 0 .1em rgba(var(--ink-rgb), .5) } -.today h1 { - border-radius: 2em; - border: .2em dotted var(--today); - margin: -.2em; -} - -/* Selection */ input { background: transparent; border: none; color: var(--ink); width: 11em; } -label:has(input[value]) { - display: block; -} +label:has(input[value]) { display: block } + .selected { - background: var(--select) !important; - border: 2px solid rgba(var(--ink-rgb), 0.3); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3); + background: var(--select); + border: 2px solid rgba(var(--ink-rgb), .3); + box-shadow: inset 0 0 0 1px rgba(255,255,255,.3); } -.selected h1, -:is(.range-start,.range-end,.range-middle,.range-single) h1 { - background: transparent !important; - color: var(--panel) !important; - font-weight: 700; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +.selected .event { opacity: .7 } + +.today h1 { + border-radius: 2em; + background: var(--today); + border: .2em solid var(--today); + margin: -.2em; } -/* Ensure events don't interfere with selection visibility */ -.selected .event { - opacity: 0.7; -} -/* Month labels in jogwheel column */ .month-name-label { grid-column: -2 / -1; font-size: 2em; @@ -324,11 +315,9 @@ label:has(input[value]) { z-index: 15; overflow: visible; position: absolute; - top: 0; - right: 0; + top: 0; right: 0; width: 100%; } - .month-name-label > span { display: inline-block; white-space: nowrap; diff --git a/calendar.js b/calendar.js index 93d2e09..0e6d13e 100644 --- a/calendar.js +++ b/calendar.js @@ -1,4 +1,4 @@ -// calendar.js — Infinite scrolling week-by-week +// calendar.js — Infinite scrolling week-by-week with overlay event rendering const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] const DAY_MS = 86400000 const WEEK_MS = 7 * DAY_MS @@ -53,10 +53,10 @@ class InfiniteCalendar { this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday // unified selection state (single or range) - this.selStart = null // 'YYYY-MM-DD' - this.selEnd = null // 'YYYY-MM-DD' + this.selStart = null + this.selEnd = null this.isDragging = false - this.dragAnchor = null // 'YYYY-MM-DD' + this.dragAnchor = null this.init() } @@ -283,8 +283,6 @@ class InfiniteCalendar { weekEl.style.height = `${this.rowHeight}px` this.content.appendChild(weekEl) this.visibleWeeks.set(vw, weekEl) - - // Add events to the newly created week this.addEventsToWeek(weekEl, vw) } @@ -309,6 +307,31 @@ class InfiniteCalendar { wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}` weekDiv.appendChild(wkLabel) + // days grid container to host cells and overlay + const daysGrid = document.createElement('div') + daysGrid.className = 'days-grid' + daysGrid.style.position = 'relative' + daysGrid.style.display = 'grid' + daysGrid.style.gridTemplateColumns = 'repeat(7, 1fr)' + daysGrid.style.gridAutoRows = '1fr' + daysGrid.style.height = '100%' + daysGrid.style.width = '100%' + weekDiv.appendChild(daysGrid) + + // overlay positioned above cells, same 7-col grid + const overlay = document.createElement('div') + overlay.className = 'week-overlay' + overlay.style.position = 'absolute' + overlay.style.inset = '0' + overlay.style.pointerEvents = 'none' + overlay.style.display = 'grid' + overlay.style.gridTemplateColumns = 'repeat(7, 1fr)' + overlay.style.gridAutoRows = '1fr' + overlay.style.zIndex = '15' + daysGrid.appendChild(overlay) + weekDiv._overlay = overlay + weekDiv._daysGrid = daysGrid + const cur = new Date(monday) let hasFirst = false let monthToLabel = null @@ -337,26 +360,20 @@ class InfiniteCalendar { day.textContent = String(cur.getDate()) const date = toLocalString(cur) - console.log(cur, date) cell.dataset.date = date if (this.today && date === this.today) cell.classList.add('today') if (this.config.select_days > 0) { - // Allow selection start from anywhere in the cell cell.addEventListener('mousedown', e => { e.preventDefault() e.stopPropagation() this.startDrag(dateStr) }) - - // Touch events for mobile support cell.addEventListener('touchstart', e => { e.preventDefault() e.stopPropagation() this.startDrag(dateStr) }) - - // Keep cell listeners for drag continuation cell.addEventListener('mouseenter', () => { if (this.isDragging) this.updateDrag(dateStr) }) @@ -364,12 +381,9 @@ class InfiniteCalendar { e.stopPropagation() if (this.isDragging) this.endDrag(dateStr) }) - - // Touch drag continuation cell.addEventListener('touchmove', e => { if (this.isDragging) { e.preventDefault() - // Get touch position and find the element underneath const touch = e.touches[0] const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY) if (elementBelow && elementBelow.closest('.cell[data-date]')) { @@ -379,7 +393,6 @@ class InfiniteCalendar { } } }) - cell.addEventListener('touchend', e => { e.stopPropagation() if (this.isDragging) this.endDrag(dateStr) @@ -392,7 +405,7 @@ class InfiniteCalendar { } cell.appendChild(day) - weekDiv.appendChild(cell) + daysGrid.appendChild(cell) cur.setDate(cur.getDate() + 1) } @@ -539,21 +552,16 @@ class InfiniteCalendar { } setupGlobalDragHandlers() { - // Mouse drag handlers document.addEventListener('mouseup', () => { if (!this.isDragging) return this.isDragging = false document.body.style.cursor = 'default' }) - - // Touch drag handlers document.addEventListener('touchend', () => { if (!this.isDragging) return this.isDragging = false document.body.style.cursor = 'default' }) - - // Global touch move handler for smooth drag across elements document.addEventListener('touchmove', e => { if (!this.isDragging) return e.preventDefault() @@ -565,13 +573,9 @@ class InfiniteCalendar { if (touchDateStr) this.updateDrag(touchDateStr) } }, { passive: false }) - - // Prevent text selection during drag document.addEventListener('selectstart', e => { if (this.isDragging) e.preventDefault() }) - - // Prevent context menu on long touch during drag document.addEventListener('contextmenu', e => { if (this.isDragging) e.preventDefault() }) @@ -595,31 +599,27 @@ class InfiniteCalendar { this.isDragging = false this.setSelection(this.dragAnchor, dateStr) document.body.style.cursor = 'default' - - // Trigger event creation after selection with a small delay if (this.selStart && this.selEnd) { setTimeout(() => this.promptForEvent(), 100) } } - // -------- Event Management -------- - + // -------- Event Management (overlay-based) -------- + promptForEvent() { const title = prompt('Enter event title:') if (!title || title.trim() === '') { this.clearSelection() return } - this.createEvent({ title: title.trim(), startDate: this.selStart, endDate: this.selEnd }) - this.clearSelection() } - + createEvent(eventData) { const event = { id: this.eventIdCounter++, @@ -628,23 +628,19 @@ class InfiniteCalendar { endDate: eventData.endDate, color: this.generateEventColor() } - - // Add event to all dates in the range + const startDate = new Date(fromLocalString(event.startDate)) const endDate = new Date(fromLocalString(event.endDate)) - + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const dateStr = toLocalString(d) - if (!this.events.has(dateStr)) { - this.events.set(dateStr, []) - } - this.events.get(dateStr).push({...event, isSpanning: startDate < endDate}) + if (!this.events.has(dateStr)) this.events.set(dateStr, []) + this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) } - - // Re-render visible weeks to show the new event + this.refreshEvents() } - + generateEventColor() { const colors = [ '#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', @@ -652,91 +648,82 @@ class InfiniteCalendar { ] return colors[Math.floor(Math.random() * colors.length)] } - + refreshEvents() { - // Re-render events for all visible weeks - for (const [weekDateStr, weekEl] of this.visibleWeeks) { - this.addEventsToWeek(weekEl, weekDateStr) + for (const [, weekEl] of this.visibleWeeks) { + this.addEventsToWeek(weekEl) } } - - addEventsToWeek(weekEl, weekDateStr) { - const cells = weekEl.querySelectorAll('.cell[data-date]') - - // Remove existing event elements from both week and individual cells - weekEl.querySelectorAll('.event-span').forEach(el => el.remove()) - cells.forEach(cell => { - cell.querySelectorAll('.event-span').forEach(el => el.remove()) - }) - - // Group events by their date ranges within this week - const weekEvents = new Map() // Map of event ID to event info - + + addEventsToWeek(weekEl) { + const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid') + const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') + if (!daysGrid || !overlay) return + + const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]')) + + while (overlay.firstChild) overlay.removeChild(overlay.firstChild) + + const weekEvents = new Map() for (const cell of cells) { const dateStr = cell.dataset.date const events = this.events.get(dateStr) || [] - - events.forEach(event => { - if (!weekEvents.has(event.id)) { - weekEvents.set(event.id, { - ...event, + for (const ev of events) { + if (!weekEvents.has(ev.id)) { + weekEvents.set(ev.id, { + ...ev, startDateInWeek: dateStr, endDateInWeek: dateStr, - startCell: cell, - endCell: cell, - daysInWeek: 1 + startIdx: cells.indexOf(cell), + endIdx: cells.indexOf(cell) }) } else { - const weekEvent = weekEvents.get(event.id) - weekEvent.endDateInWeek = dateStr - weekEvent.endCell = cell - weekEvent.daysInWeek++ + const w = weekEvents.get(ev.id) + w.endDateInWeek = dateStr + w.endIdx = cells.indexOf(cell) } - }) + } } - - // Create spanning elements for each event - weekEvents.forEach((weekEvent, eventId) => { - this.createSpanningEvent(weekEl, weekEvent) - }) + + const spans = Array.from(weekEvents.values()) + .sort((a, b) => a.startIdx - b.startIdx || (b.endIdx - b.startIdx) - (a.endIdx - a.startIdx)) + + const rowsLastEnd = [] + for (const w of spans) { + let placedRow = 0 + while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++ + if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1) + rowsLastEnd[placedRow] = w.endIdx + w._row = placedRow + 1 + } + + overlay.style.gridTemplateRows = `repeat(${Math.max(1, rowsLastEnd.length)}, 1fr)` + overlay.style.rowGap = '.2em' + + for (const w of spans) this.createOverlaySpan(overlay, w) } - - createSpanningEvent(weekEl, weekEvent) { - const spanEl = document.createElement('div') - spanEl.className = 'event-span' - spanEl.style.backgroundColor = weekEvent.color - spanEl.textContent = weekEvent.title - spanEl.title = `${weekEvent.title} (${weekEvent.startDate === weekEvent.endDate ? weekEvent.startDate : weekEvent.startDate + ' - ' + weekEvent.endDate})` - - // Get all cells in the week (excluding week label and overlay) - const cells = Array.from(weekEl.querySelectorAll('.cell[data-date]')) - const startCellIndex = cells.indexOf(weekEvent.startCell) - const endCellIndex = cells.indexOf(weekEvent.endCell) - const spanDays = endCellIndex - startCellIndex + 1 - - // Use CSS custom properties for positioning - spanEl.style.setProperty('--start-day', startCellIndex) - spanEl.style.setProperty('--span-days', spanDays) - - // Style the spanning event - spanEl.style.position = 'absolute' - spanEl.style.top = '0.25em' - spanEl.style.height = '1.2em' - spanEl.style.zIndex = '15' - spanEl.style.fontSize = '0.75em' - spanEl.style.padding = '0.1em 0.3em' - spanEl.style.borderRadius = '0.2em' - spanEl.style.color = 'white' - spanEl.style.fontWeight = '500' - spanEl.style.whiteSpace = 'nowrap' - spanEl.style.overflow = 'hidden' - spanEl.style.textOverflow = 'ellipsis' - spanEl.style.cursor = 'pointer' - spanEl.style.lineHeight = '1.2' - spanEl.style.pointerEvents = 'auto' - - // Append to the week element (not individual cells) - weekEl.appendChild(spanEl) + + createOverlaySpan(overlay, w) { + const span = document.createElement('div') + span.className = 'event-span' + span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}` + span.style.gridRow = `${w._row}` + span.style.height = '1.2em' + span.style.borderRadius = '.4em' + span.style.fontSize = '.75em' + span.style.lineHeight = '1.2' + span.style.padding = '0 .5em' + span.style.whiteSpace = 'nowrap' + span.style.overflow = 'hidden' + span.style.textOverflow = 'ellipsis' + span.style.background = w.color + span.style.color = 'white' + span.style.fontWeight = '600' + span.style.pointerEvents = 'auto' + span.style.zIndex = '1' + span.textContent = w.title + span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})` + overlay.appendChild(span) } } @@ -745,4 +732,3 @@ document.addEventListener('DOMContentLoaded', () => { select_days: 14 }) }) -