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.
This commit is contained in:
Janus Knudsen 2025-08-05 23:03:08 +02:00
parent e6d6599a1e
commit 36ac8d18ab
4 changed files with 97 additions and 424 deletions

View file

@ -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
*/