refactor(schedule): consolidate scrollbars onto single parent element

- Move horizontal scroll control to parent .scroll-wrapper element
- Both vertical and horizontal scrollbars now on same container
- Pass isHorizontalScrollMode as input to schedule-week component
- Remove duplicate scroll wrapper from schedule-week
- Maintain responsive column widths based on scroll mode
- Fixes scrollbar positioning and coordination issues

This ensures both scrollbars are managed by the same element, providing
better UX and preventing scrollbar positioning conflicts.
This commit is contained in:
Johannes Millan 2026-01-21 14:33:31 +01:00
parent f4d3c61ec9
commit d2ab8e6482
6 changed files with 227 additions and 230 deletions

View file

@ -1,191 +1,189 @@
<div class="horizontal-scroll-wrapper">
<header
class="week-header"
[class.isInPanel]="isInPanel()"
>
<div class="days">
<div class="filler"><!--for time --></div>
@for (day of daysToShow(); track $index) {
<div class="day">
@if (day === todayDateStr()) {
<mat-icon>wb_sunny</mat-icon>
}
<div class="day-num">{{ day | localeDate: 'd' }}</div>
<div class="day-day">{{ day | localeDate: 'EEE' }}</div>
</div>
}
</div>
</header>
<div
#gridContainer
class="grid-container"
(click)="onGridClick($event)"
(mousemove)="onMoveOverGrid($event)"
>
<!-- Time -->
@for (time of times(); track $index) {
<div
class="time"
style="grid-row: {{ $index * FH + 1 }}"
>
{{ time }}
</div>
}
<!-- Grid Rows -->
@for (row of rowsByNr; track $index) {
<div
class="row"
style="grid-row: {{ row + 1 }}"
></div>
}
<!-- Grid Cols -->
<header
class="week-header"
[class.isInPanel]="isInPanel()"
>
<div class="days">
<div class="filler"><!--for time --></div>
@for (day of daysToShow(); track $index) {
<div
class="col"
[attr.data-day]="day"
style="grid-column: {{ $index + 2 }}; grid-row: 1 / span {{
($index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart()) - 1
}}"
></div>
<div
class="col end-of-day"
[attr.data-day]="day"
style="grid-column: {{ $index + 2 }}; grid-row: {{
$index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart()
}} / span {{
totalRows -
($index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart())
}}"
></div>
}
<!-- Work Start and End -->
@if (workStartEnd()) {
<div
id="work-start"
class="work-start"
style="grid-row: {{ workStartEnd()!.workStartRow }}"
>
<div>{{ T.F.SCHEDULE.START | translate }}</div>
</div>
<div
class="work-end"
style="grid-row: {{ workStartEnd()!.workEndRow }}"
>
<div>{{ T.F.SCHEDULE.END | translate }}</div>
</div>
}
@if (currentTimeRow() !== null) {
<div
id="current-time"
class="current-time"
style="grid-column: 2; grid-row: {{ currentTimeRow() }} / span 1"
>
<div class="circle"></div>
</div>
}
<!-- Events -->
@for (ev of safeEvents(); track ev.id) {
@if (isDraggableSE(ev)) {
<schedule-event
class="draggable"
[cdkDragData]="ev"
(cdkDragMoved)="dragMoved($event)"
(cdkDragStarted)="dragStarted($event)"
(cdkDragReleased)="dragReleased($event)"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? 75 : 0"
[event]="ev"
></schedule-event>
} @else {
<schedule-event
[event]="ev"
[cdkDragDisabled]="true"
></schedule-event>
}
}
<!-- Excess tasks planned for day -->
@for (beyondBudgetDay of safeBeyondBudget(); track i; let i = $index) {
@if (beyondBudgetDay.length > 0) {
<div
class="excess-entries"
style="grid-column: {{ i + 2 }}"
>
<div
class="excess-entries-header"
[matTooltipPosition]="'above'"
[matTooltip]="
'Tasks planned for day, but that are beyond the available time budget'
"
>
<mat-icon>hourglass_disabled</mat-icon>
{{ beyondBudgetDay.length }}
</div>
@for (ev of beyondBudgetDay; track ev.id) {
@if (isDraggableSE(ev)) {
<schedule-event
[event]="ev"
class="draggable"
[cdkDragData]="ev"
(cdkDragMoved)="dragMoved($event)"
(cdkDragStarted)="dragStarted($event)"
(cdkDragReleased)="dragReleased($event)"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH : 0"
></schedule-event>
} @else {
<schedule-event
[event]="ev"
[cdkDragDisabled]="true"
></schedule-event>
}
}
</div>
}
}
@if (
(!isTaskDragActive() || isCtrlPressed()) && newTaskPlaceholder();
as newTaskPlaceholder
) {
<create-task-placeholder
[isEditMode]="isCreateTaskActive()"
[time]="newTaskPlaceholder.time"
[date]="newTaskPlaceholder.date"
(editEnd)="isCreateTaskActive.set(false); this.newTaskPlaceholder.set(null)"
[style]="newTaskPlaceholder.style"
[style.opacity]="isCtrlPressed() ? 1 : null"
>
</create-task-placeholder>
}
@if (isDragging() && currentDragEvent() && dragPreviewStyle()) {
<schedule-event
class="custom-drag-preview"
[event]="currentDragEvent()!"
[style]="dragPreviewStyle()!"
[class.isShiftInsertPreview]="dragPreviewContext()?.kind === 'shift-task'"
[class.isScheduleForDay]="isShiftNoScheduleMode()"
[isDragPreview]="true"
>
@if (dragPreviewLabel()) {
<div class="drag-preview-time-badge">
{{ dragPreviewLabel() }}
</div>
<div class="day">
@if (day === todayDateStr()) {
<mat-icon>wb_sunny</mat-icon>
}
</schedule-event>
<div class="day-num">{{ day | localeDate: 'd' }}</div>
<div class="day-day">{{ day | localeDate: 'EEE' }}</div>
</div>
}
</div>
</header>
<div
#gridContainer
class="grid-container"
(click)="onGridClick($event)"
(mousemove)="onMoveOverGrid($event)"
>
<!-- Time -->
@for (time of times(); track $index) {
<div
class="time"
style="grid-row: {{ $index * FH + 1 }}"
>
{{ time }}
</div>
}
<!-- Grid Rows -->
@for (row of rowsByNr; track $index) {
<div
class="row"
style="grid-row: {{ row + 1 }}"
></div>
}
<!-- Grid Cols -->
@for (day of daysToShow(); track $index) {
<div
class="col"
[attr.data-day]="day"
style="grid-column: {{ $index + 2 }}; grid-row: 1 / span {{
($index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart()) - 1
}}"
></div>
<div
class="col end-of-day"
[attr.data-day]="day"
style="grid-column: {{ $index + 2 }}; grid-row: {{
$index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart()
}} / span {{
totalRows -
($index === 0 && currentTimeRow() !== null
? currentTimeRow()!
: endOfDayColRowStart())
}}"
></div>
}
<!-- Work Start and End -->
@if (workStartEnd()) {
<div
id="work-start"
class="work-start"
style="grid-row: {{ workStartEnd()!.workStartRow }}"
>
<div>{{ T.F.SCHEDULE.START | translate }}</div>
</div>
<div
class="work-end"
style="grid-row: {{ workStartEnd()!.workEndRow }}"
>
<div>{{ T.F.SCHEDULE.END | translate }}</div>
</div>
}
@if (currentTimeRow() !== null) {
<div
id="current-time"
class="current-time"
style="grid-column: 2; grid-row: {{ currentTimeRow() }} / span 1"
>
<div class="circle"></div>
</div>
}
<!-- Events -->
@for (ev of safeEvents(); track ev.id) {
@if (isDraggableSE(ev)) {
<schedule-event
class="draggable"
[cdkDragData]="ev"
(cdkDragMoved)="dragMoved($event)"
(cdkDragStarted)="dragStarted($event)"
(cdkDragReleased)="dragReleased($event)"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? 75 : 0"
[event]="ev"
></schedule-event>
} @else {
<schedule-event
[event]="ev"
[cdkDragDisabled]="true"
></schedule-event>
}
}
<!-- Excess tasks planned for day -->
@for (beyondBudgetDay of safeBeyondBudget(); track i; let i = $index) {
@if (beyondBudgetDay.length > 0) {
<div
class="excess-entries"
style="grid-column: {{ i + 2 }}"
>
<div
class="excess-entries-header"
[matTooltipPosition]="'above'"
[matTooltip]="
'Tasks planned for day, but that are beyond the available time budget'
"
>
<mat-icon>hourglass_disabled</mat-icon>
{{ beyondBudgetDay.length }}
</div>
@for (ev of beyondBudgetDay; track ev.id) {
@if (isDraggableSE(ev)) {
<schedule-event
[event]="ev"
class="draggable"
[cdkDragData]="ev"
(cdkDragMoved)="dragMoved($event)"
(cdkDragStarted)="dragStarted($event)"
(cdkDragReleased)="dragReleased($event)"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH : 0"
></schedule-event>
} @else {
<schedule-event
[event]="ev"
[cdkDragDisabled]="true"
></schedule-event>
}
}
</div>
}
}
@if (
(!isTaskDragActive() || isCtrlPressed()) && newTaskPlaceholder();
as newTaskPlaceholder
) {
<create-task-placeholder
[isEditMode]="isCreateTaskActive()"
[time]="newTaskPlaceholder.time"
[date]="newTaskPlaceholder.date"
(editEnd)="isCreateTaskActive.set(false); this.newTaskPlaceholder.set(null)"
[style]="newTaskPlaceholder.style"
[style.opacity]="isCtrlPressed() ? 1 : null"
>
</create-task-placeholder>
}
@if (isDragging() && currentDragEvent() && dragPreviewStyle()) {
<schedule-event
class="custom-drag-preview"
[event]="currentDragEvent()!"
[style]="dragPreviewStyle()!"
[class.isShiftInsertPreview]="dragPreviewContext()?.kind === 'shift-task'"
[class.isScheduleForDay]="isShiftNoScheduleMode()"
[isDragPreview]="true"
>
@if (dragPreviewLabel()) {
<div class="drag-preview-time-badge">
{{ dragPreviewLabel() }}
</div>
}
</schedule-event>
}
</div>
@if (showShiftKeyInfo()) {

View file

@ -18,33 +18,6 @@
// Enable horizontal scroll when viewport is too small for 7 days
&[data-horizontal-scroll] {
.horizontal-scroll-wrapper {
overflow-x: scroll; // Always show horizontal scrollbar
overflow-y: visible; // Allow vertical content to extend
// Match app's standard scrollbar styling
scrollbar-width: 4px;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
&::-webkit-scrollbar {
height: 8px; // Slightly taller than standard for horizontal
}
&::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 16px;
&:hover {
background: var(--scrollbar-thumb-hover);
}
}
}
.week-header {
// Remove left/right constraints to allow horizontal scrolling
left: auto;

View file

@ -33,9 +33,6 @@ import { formatMonthDay } from '../../../util/format-month-day.util';
import { ScheduleWeekDragService } from './schedule-week-drag.service';
import { calculatePlaceholderForGridMove } from './schedule-week-placeholder.util';
import { truncate } from '../../../util/truncate';
import { fromEvent } from 'rxjs';
import { debounceTime, map, startWith } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
const D_HOURS = 24;
@ -61,7 +58,7 @@ const D_HOURS = 24;
'[class.is-not-dragging]': '!isDragging()',
'[class.is-resizing-event]': 'isAnyEventResizing()',
'[class]': 'dragEventTypeClass()',
'[attr.data-horizontal-scroll]': 'shouldEnableHorizontalScroll()',
'[attr.data-horizontal-scroll]': 'isHorizontalScrollMode() || null',
},
})
export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
@ -69,21 +66,8 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
private _dateTimeFormatService = inject(DateTimeFormatService);
private _translateService = inject(TranslateService);
private _windowSize = toSignal(
fromEvent(window, 'resize').pipe(
startWith({ width: window.innerWidth }),
debounceTime(50),
map(() => ({ width: window.innerWidth })),
),
{ initialValue: { width: window.innerWidth } },
);
shouldEnableHorizontalScroll = computed(() => {
// Enable scroll when viewport is smaller than what's needed for 7 days
return this._windowSize().width < 1900;
});
isInPanel = input<boolean>(false);
isHorizontalScrollMode = input<boolean>(false);
events = input<ScheduleEvent[] | null>([]);
beyondBudget = input<ScheduleEvent[][] | null>([]);
daysToShow = input<string[]>([]);

View file

@ -30,7 +30,10 @@
</button>
</div>
<div class="scroll-wrapper">
<div
class="scroll-wrapper"
[attr.data-horizontal-scroll]="shouldEnableHorizontalScroll() || null"
>
@if (isMonthView()) {
<schedule-month
[events]="events()"
@ -46,6 +49,7 @@
[workStartEnd]="workStartEnd() || null"
[currentTimeRow]="currentTimeRow()"
[todayDateStr]="_todayDateStr()"
[isHorizontalScrollMode]="shouldEnableHorizontalScroll()"
></schedule-week>
}
</div>

View file

@ -15,6 +15,34 @@
overflow-y: scroll;
overflow-x: hidden;
box-sizing: border-box;
// Enable horizontal scroll when viewport is too small for 7 days
&[data-horizontal-scroll] {
overflow-x: scroll; // Show horizontal scrollbar when needed
// Match app's standard scrollbar styling
scrollbar-width: 4px;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
&::-webkit-scrollbar {
width: 4px;
height: 8px; // Slightly taller for horizontal visibility
}
&::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 16px;
&:hover {
background: var(--scrollbar-thumb-hover);
}
}
}
}
header {

View file

@ -89,6 +89,16 @@ export class ScheduleComponent {
{ initialValue: { width: window.innerWidth, height: window.innerHeight } },
);
shouldEnableHorizontalScroll = computed(() => {
const selectedView = this._currentTimeViewMode();
// Only enable horizontal scroll for week view when viewport is narrow
if (selectedView !== 'week') {
return false;
}
// Enable scroll when viewport is smaller than what's needed for 7 days
return this._windowSize().width < 1900;
});
private _daysToShowCount = computed(() => {
const size = this._windowSize();
const selectedView = this._currentTimeViewMode();