From 36ac8d18abff2e097a225e46aeddcc8d6678a523 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Tue, 5 Aug 2025 23:03:08 +0200 Subject: [PATCH] Implements native scrollbars with styling Replaces custom scrollbar implementation with native scrollbars for better performance and accessibility. Adds configuration options for scrollbar styling, including width, color, track color, hover color, and border radius. Synchronizes week header and time axis scrolling with the scrollable content. --- src/core/CalendarConfig.ts | 7 + src/managers/ScrollManager.ts | 375 +++++----------------------- src/types/CalendarTypes.ts | 7 + wwwroot/css/calendar-layout-css.css | 132 ++-------- 4 files changed, 97 insertions(+), 424 deletions(-) diff --git a/src/core/CalendarConfig.ts b/src/core/CalendarConfig.ts index e63aa5c..8eb0fe4 100644 --- a/src/core/CalendarConfig.ts +++ b/src/core/CalendarConfig.ts @@ -39,6 +39,13 @@ export class CalendarConfig { showWorkHours: true, fitToWidth: false, // Fit columns to calendar width (no horizontal scroll) + // Scrollbar styling + scrollbarWidth: 16, // Width of scrollbar in pixels + scrollbarColor: '#666', // Scrollbar thumb color + scrollbarTrackColor: '#f0f0f0', // Scrollbar track color + scrollbarHoverColor: '#b53f7aff', // Scrollbar thumb hover color + scrollbarBorderRadius: 6, // Border radius for scrollbar thumb + // Interaction settings allowDrag: true, allowResize: true, diff --git a/src/managers/ScrollManager.ts b/src/managers/ScrollManager.ts index 3068035..c5664fc 100644 --- a/src/managers/ScrollManager.ts +++ b/src/managers/ScrollManager.ts @@ -5,31 +5,14 @@ import { calendarConfig } from '../core/CalendarConfig'; import { EventTypes } from '../constants/EventTypes'; /** - * Manages custom scrolling functionality for the calendar + * Manages scrolling functionality for the calendar using native scrollbars */ export class ScrollManager { - // Vertical scrolling - private rightColumn: HTMLElement | null = null; - private scrollHandle: HTMLElement | null = null; private scrollableContent: HTMLElement | null = null; private calendarContainer: HTMLElement | null = null; private timeAxis: HTMLElement | null = null; - private resizeObserver: ResizeObserver | null = null; - private isDragging: boolean = false; - private dragStartY: number = 0; - private scrollStartTop: number = 0; - private maxScrollTop: number = 0; - private handleHeight: number = 40; - - // Horizontal scrolling - private bottomMiddleSpacer: HTMLElement | null = null; - private horizontalScrollHandle: HTMLElement | null = null; private weekHeader: HTMLElement | null = null; - private isHorizontalDragging: boolean = false; - private dragStartX: number = 0; - private scrollStartLeft: number = 0; - private maxScrollLeft: number = 0; - private horizontalHandleWidth: number = 40; + private resizeObserver: ResizeObserver | null = null; constructor() { this.init(); @@ -46,14 +29,18 @@ export class ScrollManager { this.setupScrolling(); }); - // Handle mouse events for dragging (both vertical and horizontal) - document.addEventListener('mousemove', this.handleMouseMove.bind(this)); - document.addEventListener('mouseup', this.handleMouseUp.bind(this)); - // Handle window resize window.addEventListener('resize', () => { this.updateScrollableHeight(); }); + + // Handle config updates for scrollbar styling + eventBus.on(EventTypes.CONFIG_UPDATE, (event: CustomEvent) => { + const { key } = event.detail; + if (key.startsWith('scrollbar')) { + this.applyScrollbarStyling(); + } + }); } /** @@ -62,221 +49,82 @@ export class ScrollManager { private setupScrolling(): void { this.findElements(); - if (this.rightColumn && this.scrollableContent && this.calendarContainer) { + if (this.scrollableContent && this.calendarContainer) { this.setupResizeObserver(); this.updateScrollableHeight(); - this.createScrollHandle(); - this.hideNativeScrollbar(); this.setupScrollSynchronization(); - this.calculateScrollBounds(); - this.updateHandlePosition(); + this.applyScrollbarStyling(); } - // Setup horizontal scrolling - if (this.bottomMiddleSpacer && this.scrollableContent && this.weekHeader) { - this.createHorizontalScrollHandle(); + // Setup horizontal scrolling synchronization + if (this.scrollableContent && this.weekHeader) { this.setupHorizontalScrollSynchronization(); - this.calculateHorizontalScrollBounds(); - this.updateHorizontalHandlePosition(); } } + /** + * Apply scrollbar styling from configuration + */ + private applyScrollbarStyling(): void { + if (!this.scrollableContent) return; + + // Get scrollbar configuration + const scrollbarWidth = calendarConfig.get('scrollbarWidth'); + const scrollbarColor = calendarConfig.get('scrollbarColor'); + const scrollbarTrackColor = calendarConfig.get('scrollbarTrackColor'); + const scrollbarHoverColor = calendarConfig.get('scrollbarHoverColor'); + const scrollbarBorderRadius = calendarConfig.get('scrollbarBorderRadius'); + + // Apply CSS custom properties to both the element and document root + const root = document.documentElement; + + // Set on scrollable content + this.scrollableContent.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`); + this.scrollableContent.style.setProperty('--scrollbar-color', scrollbarColor); + this.scrollableContent.style.setProperty('--scrollbar-track-color', scrollbarTrackColor); + this.scrollableContent.style.setProperty('--scrollbar-hover-color', scrollbarHoverColor); + this.scrollableContent.style.setProperty('--scrollbar-border-radius', `${scrollbarBorderRadius}px`); + + // Also set on root for global access + root.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`); + root.style.setProperty('--scrollbar-color', scrollbarColor); + root.style.setProperty('--scrollbar-track-color', scrollbarTrackColor); + root.style.setProperty('--scrollbar-hover-color', scrollbarHoverColor); + root.style.setProperty('--scrollbar-border-radius', `${scrollbarBorderRadius}px`); + + console.log('ScrollManager: Applied scrollbar styling', { + width: `${scrollbarWidth}px`, + color: scrollbarColor, + trackColor: scrollbarTrackColor, + hoverColor: scrollbarHoverColor, + borderRadius: `${scrollbarBorderRadius}px` + }); + } + /** * Find DOM elements needed for scrolling */ private findElements(): void { - this.rightColumn = document.querySelector('swp-right-column'); this.scrollableContent = document.querySelector('swp-scrollable-content'); this.calendarContainer = document.querySelector('swp-calendar-container'); this.timeAxis = document.querySelector('swp-time-axis'); - - // Horizontal scrolling elements - this.bottomMiddleSpacer = document.querySelector('swp-bottom-middle-spacer'); this.weekHeader = document.querySelector('swp-week-header'); console.log('ScrollManager: Found elements:', { - rightColumn: !!this.rightColumn, - bottomMiddleSpacer: !!this.bottomMiddleSpacer, scrollableContent: !!this.scrollableContent, + calendarContainer: !!this.calendarContainer, + timeAxis: !!this.timeAxis, weekHeader: !!this.weekHeader }); } - /** - * Create and add scroll handle to right column - */ - private createScrollHandle(): void { - if (!this.rightColumn) return; - - // Remove existing handle if any - const existingHandle = this.rightColumn.querySelector('swp-scroll-handle'); - if (existingHandle) { - existingHandle.remove(); - } - - // Create new handle - this.scrollHandle = document.createElement('swp-scroll-handle'); - this.scrollHandle.addEventListener('mousedown', this.handleMouseDown.bind(this)); - - this.rightColumn.appendChild(this.scrollHandle); - } - - /** - * Calculate scroll bounds based on content and container heights - */ - private calculateScrollBounds(): void { - if (!this.scrollableContent || !this.rightColumn) return; - - const contentHeight = this.scrollableContent.scrollHeight; - const containerHeight = this.scrollableContent.clientHeight; - // Use container height as track height since right column should match scrollable area - const trackHeight = containerHeight; - - console.log('ScrollManager Debug:'); - console.log('- contentHeight (scrollHeight):', contentHeight); - console.log('- containerHeight (clientHeight):', containerHeight); - console.log('- trackHeight (using containerHeight):', trackHeight); - console.log('- scrollableContent element:', this.scrollableContent); - - this.maxScrollTop = Math.max(0, contentHeight - containerHeight); - - // Calculate proportional handle height based on content ratio - if (contentHeight > 0 && containerHeight > 0) { - const visibleRatio = containerHeight / contentHeight; - this.handleHeight = Math.max(20, Math.min(trackHeight * visibleRatio, trackHeight - 10)); - } else { - this.handleHeight = 40; // fallback - } - - console.log('- maxScrollTop:', this.maxScrollTop); - console.log('- visibleRatio:', (containerHeight / contentHeight).toFixed(3)); - console.log('- calculated handleHeight:', this.handleHeight); - - // Update handle height in DOM - if (this.scrollHandle) { - this.scrollHandle.style.height = `${this.handleHeight}px`; - } - } - - /** - * Handle mouse down on scroll handle - */ - private handleMouseDown(e: MouseEvent): void { - e.preventDefault(); - this.isDragging = true; - this.dragStartY = e.clientY; - - if (this.scrollHandle && this.scrollableContent) { - this.scrollHandle.classList.add('dragging'); - this.scrollStartTop = this.scrollableContent.scrollTop; - } - } - - /** - * Handle mouse move during drag - */ - private handleMouseMove(e: MouseEvent): void { - // Handle vertical dragging - if (this.isDragging && this.scrollHandle && this.scrollableContent) { - e.preventDefault(); - - const deltaY = e.clientY - this.dragStartY; - // Use container height as track height - const trackHeight = this.scrollableContent.clientHeight - this.handleHeight; - - // Ensure trackHeight is positive to avoid division by zero - if (trackHeight > 0) { - const scrollRatio = deltaY / trackHeight; - const newScrollTop = this.scrollStartTop + (scrollRatio * this.maxScrollTop); - - // Clamp scroll position - const clampedScrollTop = Math.max(0, Math.min(newScrollTop, this.maxScrollTop)); - - // Apply scroll to content - this.scrollableContent.scrollTop = clampedScrollTop; - - // Update handle position (this will also trigger time-axis sync via scroll event) - this.updateHandlePosition(); - } - } - - // Handle horizontal dragging - if (this.isHorizontalDragging && this.horizontalScrollHandle && this.scrollableContent) { - e.preventDefault(); - - const deltaX = e.clientX - this.dragStartX; - // Use container width as track width - const trackWidth = this.scrollableContent.clientWidth - this.horizontalHandleWidth; - - // Ensure trackWidth is positive to avoid division by zero - if (trackWidth > 0) { - const scrollRatio = deltaX / trackWidth; - const newScrollLeft = this.scrollStartLeft + (scrollRatio * this.maxScrollLeft); - - // Clamp scroll position - const clampedScrollLeft = Math.max(0, Math.min(newScrollLeft, this.maxScrollLeft)); - - // Apply scroll to content - this.scrollableContent.scrollLeft = clampedScrollLeft; - - // Update handle position (this will also trigger week-header sync via scroll event) - this.updateHorizontalHandlePosition(); - } - } - } - - /** - * Handle mouse up to end drag - */ - private handleMouseUp(e: MouseEvent): void { - // Handle vertical drag end - if (this.isDragging) { - this.isDragging = false; - - if (this.scrollHandle) { - this.scrollHandle.classList.remove('dragging'); - } - } - - // Handle horizontal drag end - if (this.isHorizontalDragging) { - this.isHorizontalDragging = false; - - if (this.horizontalScrollHandle) { - this.horizontalScrollHandle.classList.remove('dragging'); - } - } - } - - /** - * Update handle position based on current scroll - */ - private updateHandlePosition(): void { - if (!this.scrollHandle || !this.scrollableContent) return; - - const scrollTop = this.scrollableContent.scrollTop; - const scrollRatio = this.maxScrollTop > 0 ? scrollTop / this.maxScrollTop : 0; - // Use container height as track height - const trackHeight = this.scrollableContent.clientHeight - this.handleHeight; - const handleTop = Math.max(0, Math.min(scrollRatio * trackHeight, trackHeight)); - - this.scrollHandle.style.top = `${handleTop}px`; - - // Debug logging for handle position - if (scrollTop % 200 === 0) { // Log every 200px to avoid spam - console.log(`ScrollManager: Handle position - scrollTop: ${scrollTop}, ratio: ${scrollRatio.toFixed(3)}, handleTop: ${handleTop.toFixed(1)}, trackHeight: ${trackHeight}`); - } - } - /** * Scroll to specific position */ scrollTo(scrollTop: number): void { if (!this.scrollableContent) return; - const clampedScrollTop = Math.max(0, Math.min(scrollTop, this.maxScrollTop)); - this.scrollableContent.scrollTop = clampedScrollTop; - this.updateHandlePosition(); + this.scrollableContent.scrollTop = scrollTop; } /** @@ -331,8 +179,8 @@ export class ScrollManager { // Calculate available height for scrollable content const availableHeight = containerRect.height - headerHeight; - // Calculate available width (container width minus time-axis and scrollbar) - const availableWidth = containerRect.width - 60 - 20; // 60px time-axis, 20px scrollbar + // Calculate available width (container width minus time-axis) + const availableWidth = containerRect.width - 60; // 60px time-axis console.log('ScrollManager: Dynamic height calculation'); console.log('- Container height:', containerRect.height); @@ -348,23 +196,6 @@ export class ScrollManager { if (availableWidth > 0) { this.scrollableContent.style.width = `${availableWidth}px`; } - - // Recalculate scroll bounds after dimension changes - setTimeout(() => { - this.calculateScrollBounds(); - this.calculateHorizontalScrollBounds(); - this.updateHandlePosition(); - this.updateHorizontalHandlePosition(); - }, 0); - } - - /** - * Hide native scrollbar while keeping scroll functionality - * Note: Scrollbar hiding is now handled in CSS file - */ - private hideNativeScrollbar(): void { - // Scrollbar hiding is now handled in CSS file - // No JavaScript needed here anymore } /** @@ -385,7 +216,6 @@ export class ScrollManager { scrollTimeout = requestAnimationFrame(() => { this.syncTimeAxisPosition(); - this.updateHandlePosition(); }); }); } @@ -410,93 +240,6 @@ export class ScrollManager { } } - /** - * Create and add horizontal scroll handle to bottom middle spacer - */ - private createHorizontalScrollHandle(): void { - if (!this.bottomMiddleSpacer) return; - - // Remove existing handle if any - const existingHandle = this.bottomMiddleSpacer.querySelector('swp-horizontal-scroll-handle'); - if (existingHandle) { - existingHandle.remove(); - } - - // Create new handle - this.horizontalScrollHandle = document.createElement('swp-horizontal-scroll-handle'); - this.horizontalScrollHandle.addEventListener('mousedown', this.handleHorizontalMouseDown.bind(this)); - - this.bottomMiddleSpacer.appendChild(this.horizontalScrollHandle); - } - - /** - * Calculate horizontal scroll bounds based on content and container widths - */ - private calculateHorizontalScrollBounds(): void { - if (!this.scrollableContent || !this.bottomMiddleSpacer) return; - - const contentWidth = this.scrollableContent.scrollWidth; - const containerWidth = this.scrollableContent.clientWidth; - const trackWidth = containerWidth; - - console.log('ScrollManager Horizontal Debug:'); - console.log('- contentWidth (scrollWidth):', contentWidth); - console.log('- containerWidth (clientWidth):', containerWidth); - console.log('- trackWidth (using containerWidth):', trackWidth); - - this.maxScrollLeft = Math.max(0, contentWidth - containerWidth); - - // Calculate proportional handle width based on content ratio - if (contentWidth > 0 && containerWidth > 0) { - const visibleRatio = containerWidth / contentWidth; - this.horizontalHandleWidth = Math.max(20, Math.min(trackWidth * visibleRatio, trackWidth - 10)); - } else { - this.horizontalHandleWidth = 40; // fallback - } - - console.log('- maxScrollLeft:', this.maxScrollLeft); - console.log('- visibleRatio:', (containerWidth / contentWidth).toFixed(3)); - console.log('- calculated horizontalHandleWidth:', this.horizontalHandleWidth); - - // Update handle width in DOM - if (this.horizontalScrollHandle) { - this.horizontalScrollHandle.style.width = `${this.horizontalHandleWidth}px`; - } - } - - /** - * Handle mouse down on horizontal scroll handle - */ - private handleHorizontalMouseDown(e: MouseEvent): void { - e.preventDefault(); - this.isHorizontalDragging = true; - this.dragStartX = e.clientX; - - if (this.horizontalScrollHandle && this.scrollableContent) { - this.horizontalScrollHandle.classList.add('dragging'); - this.scrollStartLeft = this.scrollableContent.scrollLeft; - } - } - - /** - * Update horizontal handle position based on current scroll - */ - private updateHorizontalHandlePosition(): void { - if (!this.horizontalScrollHandle || !this.scrollableContent) return; - - const scrollLeft = this.scrollableContent.scrollLeft; - const scrollRatio = this.maxScrollLeft > 0 ? scrollLeft / this.maxScrollLeft : 0; - const trackWidth = this.scrollableContent.clientWidth - this.horizontalHandleWidth; - const handleLeft = Math.max(0, Math.min(scrollRatio * trackWidth, trackWidth)); - - this.horizontalScrollHandle.style.left = `${handleLeft}px`; - - // Debug logging for handle position - if (scrollLeft % 200 === 0) { // Log every 200px to avoid spam - console.log(`ScrollManager: Horizontal handle position - scrollLeft: ${scrollLeft}, ratio: ${scrollRatio.toFixed(3)}, handleLeft: ${handleLeft.toFixed(1)}, trackWidth: ${trackWidth}`); - } - } - /** * Setup horizontal scroll synchronization between scrollable content and week header */ @@ -508,7 +251,6 @@ export class ScrollManager { // Listen to horizontal scroll events this.scrollableContent.addEventListener('scroll', () => { this.syncWeekHeaderPosition(); - this.updateHorizontalHandlePosition(); }); } @@ -529,7 +271,6 @@ export class ScrollManager { } } - /** * Cleanup resources */ diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts index 17c2796..c04701c 100644 --- a/src/types/CalendarTypes.ts +++ b/src/types/CalendarTypes.ts @@ -39,6 +39,13 @@ export interface CalendarConfig { showWorkHours: boolean; fitToWidth: boolean; // Fit columns to calendar width vs horizontal scroll + // Scrollbar styling + scrollbarWidth: number; // Width of scrollbar in pixels + scrollbarColor: string; // Scrollbar thumb color + scrollbarTrackColor: string; // Scrollbar track color + scrollbarHoverColor: string; // Scrollbar thumb hover color + scrollbarBorderRadius: number; // Border radius for scrollbar thumb + // Interaction settings allowDrag: boolean; allowResize: boolean; diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css index 6d319a0..ac7803f 100644 --- a/wwwroot/css/calendar-layout-css.css +++ b/wwwroot/css/calendar-layout-css.css @@ -38,8 +38,8 @@ swp-calendar-nav { /* Calendar container grid - POC structure */ swp-calendar-container { display: grid; - grid-template-columns: 60px 1fr 20px; - grid-template-rows: auto 1fr 20px; + grid-template-columns: 60px 1fr; + grid-template-rows: auto 1fr; height: 100%; overflow: hidden; position: relative; @@ -60,22 +60,10 @@ swp-header-spacer { } -/* Right header spacer for scrollbar alignment */ -swp-right-header-spacer { - grid-column: 3; - grid-row: 1; - height: calc(var(--header-height) + var(--all-day-row-height)); /* Dynamic height including all-day events */ - background: var(--color-surface); - border-left: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - z-index: 5; /* Higher than time-axis to cover it when scrolling */ - position: relative; - transition: height 300ms ease; /* Smooth height transitions */ -} /* Week container for sliding */ swp-week-container { - grid-column: 2 / 3; + grid-column: 2; grid-row: 1 / 3; display: grid; grid-template-rows: auto 1fr; @@ -85,15 +73,6 @@ swp-week-container { overflow: hidden; } -/* Right column for scrollbar */ -swp-right-column { - grid-column: 3; - grid-row: 2; - background: #f0f0f0; - border-left: 2px solid #333; - position: relative; - overflow: hidden; -} /* Time axis */ swp-time-axis { @@ -116,81 +95,8 @@ swp-time-axis-content { position: relative; } -/* Right bottom spacer */ -swp-right-bottom-spacer { - grid-column: 3; - grid-row: 3; - height: 20px; - background: var(--color-surface); - border-left: 1px solid var(--color-border); - border-top: 1px solid var(--color-border); -} - -/* Scroll handle */ -swp-scroll-handle { - position: absolute; - top: 0; - left: 2px; - width: 16px; - height: 40px; - background: #666; - border-radius: 8px; - cursor: grab; - transition: background-color 0.2s ease; - z-index: 5; -} - -swp-scroll-handle:hover { - background: #333; -} - -swp-scroll-handle.dragging { - background: #007bff; -} - -/* Bottom row for horizontal scrollbar */ -swp-bottom-spacer { - grid-column: 1; - grid-row: 3; - height: 20px; - background: var(--color-surface); - border-right: 1px solid var(--color-border); - border-top: 1px solid var(--color-border); -} - -/* Bottom middle spacer */ -swp-bottom-middle-spacer { - grid-column: 2; - grid-row: 3; - height: 20px; - background: #f0f0f0; - border-top: 2px solid #333; - overflow: hidden; - position: relative; -} -/* Horizontal scroll handle */ -swp-horizontal-scroll-handle { - position: absolute; - left: 0; - top: 2px; - width: 40px; - height: 16px; - background: #666; - border-radius: 8px; - cursor: grab; - transition: background-color 0.2s ease; - z-index: 5; -} - -swp-horizontal-scroll-handle:hover { - background: #333; -} - -swp-horizontal-scroll-handle.dragging { - background: #007bff; -} swp-hour-marker { height: var(--hour-height); @@ -321,15 +227,31 @@ swp-scrollable-content { position: relative; display: grid; /* Height and width will be set dynamically by ScrollManager via ResizeObserver */ - - /* Hide native scrollbars */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ } -/* Chrome/Safari/Opera */ +/* Style native scrollbars for Webkit browsers (Chrome, Safari, Edge) */ swp-scrollable-content::-webkit-scrollbar { - display: none; + width: var(--scrollbar-width, 12px); + height: var(--scrollbar-width, 12px); +} + +swp-scrollable-content::-webkit-scrollbar-track { + background: var(--scrollbar-track-color, #f0f0f0); +} + +swp-scrollable-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-color, #666); + border-radius: var(--scrollbar-border-radius, 6px); +} + +swp-scrollable-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-hover-color, #333); +} + +/* Style native scrollbars for Firefox */ +swp-scrollable-content { + scrollbar-width: auto; /* Let it use the webkit width */ + scrollbar-color: var(--scrollbar-color, #666) var(--scrollbar-track-color, #f0f0f0); } /* Fit to width mode - disable horizontal scroll */ @@ -337,10 +259,6 @@ swp-calendar[data-fit-to-width="true"] swp-scrollable-content { overflow-x: hidden; } -/* Hide horizontal scrollbar by collapsing bottom row */ -swp-calendar[data-fit-to-width="true"] swp-calendar-container { - grid-template-rows: auto 1fr 0px; -} /* Time grid */ swp-time-grid {