fix(calendar): improve toolbar UX and fix build warnings

- Merge hours filter toggle and time range selector into single button
  - Click toggles filter on/off
  - Right-click (desktop) / long-press (mobile) opens time range dropdown
- Add overflow indicators for events outside visible time range
  - Show colored lines at top/bottom edge for hidden events
  - Works in DayView, WeekView, and MultiDayView
- Fix portal pattern for dropdown z-index in PillCalendarSelector
- Fix all build warnings:
  - Remove unused .task-drag-ghost CSS in WeekView/MultiDayView
  - Remove unused imports in MonthView
  - Add ARIA role to TodoDetailModal backdrop
  - Change labels to spans in PillTimeRangeSelector
  - Convert button to div with role=button in ThemeCard
  - Replace deprecated svelte:component with dynamic component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-12 13:03:31 +01:00
parent f2ac3e245e
commit 448cfb9010
8 changed files with 449 additions and 149 deletions

View file

@ -1,131 +1,189 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { CalendarViewType } from '@calendar/shared';
import {
PillToolbar,
PillToolbarButton,
PillToolbarDivider,
PillTimeRangeSelector,
PillViewSwitcher,
} from '@manacore/shared-ui';
import PillCalendarSelector from './PillCalendarSelector.svelte';
import { PillToolbar, PillToolbarDivider } from '@manacore/shared-ui';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
};
// Views to show in selector
const visibleViews: CalendarViewType[] = [
'day',
'5day',
'week',
'10day',
'14day',
'month',
'year',
];
// Convert to ViewOptions for PillViewSwitcher
const viewOptions = visibleViews.map((type) => ({
id: type,
label: viewLabels[type],
title: viewLabels[type],
}));
// Hours change handlers
function handleStartHourChange(hour: number) {
settingsStore.set('dayStartHour', hour);
interface Props {
isSidebarMode?: boolean;
isCollapsed?: boolean;
onModeChange?: (isSidebar: boolean) => void;
onCollapsedChange?: (isCollapsed: boolean) => void;
}
function handleEndHourChange(hour: number) {
settingsStore.set('dayEndHour', hour);
let {
isSidebarMode = false,
isCollapsed = false,
onModeChange,
onCollapsedChange,
}: Props = $props();
function toggleSidebarMode() {
onModeChange?.(!isSidebarMode);
}
function handleViewChange(type: string) {
viewStore.setViewType(type as CalendarViewType);
function collapseToolbar() {
onCollapsedChange?.(true);
}
function expandToolbar() {
onCollapsedChange?.(false);
}
</script>
<PillToolbar position="bottom" bottomOffset="140px">
<!-- Calendar selector -->
<PillCalendarSelector direction="up" embedded={true} />
{#if !isCollapsed}
<PillToolbar position="bottom" bottomOffset={isSidebarMode ? '0px' : '70px'}>
<CalendarToolbarContent />
<PillToolbarDivider />
<PillToolbarDivider />
<!-- Today button -->
<PillToolbarButton onclick={() => viewStore.goToToday()} title="Zum heutigen Tag springen">
Heute
</PillToolbarButton>
<!-- Layout Control -->
<div class="segmented-control glass-pill">
<button onclick={collapseToolbar} class="segment-btn" title="Toolbar minimieren">
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div class="segment-divider"></div>
<button
onclick={toggleSidebarMode}
class="segment-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
/>
{:else}
<!-- Sidebar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
/>
{/if}
</svg>
</button>
</div>
</PillToolbar>
{/if}
<PillToolbarDivider />
<!-- Navigation -->
<PillToolbarButton onclick={() => viewStore.goToPrevious()} title="Zurück" iconOnly>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</PillToolbarButton>
<PillToolbarButton onclick={() => viewStore.goToNext()} title="Weiter" iconOnly>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</PillToolbarButton>
<PillToolbarDivider />
<!-- Weekdays filter -->
<PillToolbarButton
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
active={settingsStore.showOnlyWeekdays}
title="Nur Wochentage anzeigen (Mo-Fr)"
<!-- FAB for collapsed state -->
{#if isCollapsed}
<button
onclick={expandToolbar}
class="toolbar-fab glass-pill"
class:sidebar-mode={isSidebarMode}
title="Toolbar anzeigen"
>
Mo-Fr
</PillToolbarButton>
<!-- Hours filter -->
<PillToolbarButton
onclick={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
active={settingsStore.filterHoursEnabled}
title="Stundenfilter ein/aus"
iconOnly
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
</PillToolbarButton>
</button>
{/if}
<!-- Time range selector -->
<PillTimeRangeSelector
startHour={settingsStore.dayStartHour}
endHour={settingsStore.dayEndHour}
onStartHourChange={handleStartHourChange}
onEndHourChange={handleEndHourChange}
direction="up"
embedded={true}
/>
<style>
.segmented-control {
display: flex;
align-items: center;
padding: 0.125rem;
gap: 0;
}
<PillToolbarDivider />
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 9999px;
}
<!-- View selector -->
<PillViewSwitcher
options={viewOptions}
value={viewStore.viewType}
onChange={handleViewChange}
primaryColor="#3b82f6"
embedded={true}
/>
</PillToolbar>
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.segment-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
border-radius: 9999px;
transition: all 0.15s ease;
}
.segment-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: hsl(var(--color-foreground));
}
:global(.dark) .segment-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.segment-divider {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.125rem;
}
:global(.dark) .segment-divider {
background: rgba(255, 255, 255, 0.15);
}
.segment-icon {
width: 1rem;
height: 1rem;
}
/* FAB for collapsed state - positioned right, above PillNav FAB */
.toolbar-fab {
position: fixed;
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); /* Above PillNav FAB */
right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
border: none;
border-radius: 9999px 0 0 9999px;
transition: all 0.3s ease;
}
.toolbar-fab.sidebar-mode {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fab-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -14,6 +14,12 @@
import { onMount, tick } from 'svelte';
import SunCalc from 'suncalc';
interface Props {
isSidebarMode?: boolean;
}
let { isSidebarMode = false }: Props = $props();
// Get event count for a day (max 5 dots displayed)
function getEventCount(date: Date): number {
const events = eventsStore.getEventsForDay(date, false);
@ -208,7 +214,7 @@
});
</script>
<div class="date-strip-wrapper">
<div class="date-strip-wrapper" class:sidebar-mode={isSidebarMode}>
{#if !isTodayVisible}
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button"> Heute </button>
{/if}
@ -273,7 +279,7 @@
<style>
.date-strip-wrapper {
position: fixed;
bottom: calc(200px + env(safe-area-inset-bottom, 0px));
bottom: calc(200px + env(safe-area-inset-bottom, 0px)); /* Above InputBar */
left: 0;
right: 0;
z-index: 48;
@ -281,6 +287,12 @@
flex-direction: column;
align-items: center;
pointer-events: none;
transition: bottom 0.3s ease;
}
/* When PillNav is in sidebar mode, no PillNav/Toolbar at bottom - just InputBar */
.date-strip-wrapper.sidebar-mode {
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
}
.today-button {

View file

@ -68,9 +68,63 @@
return () => clearInterval(interval);
});
let timedEvents = $derived(
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay)
);
// Get timed events, filtering out those outside visible range when hour filter is enabled
let timedEvents = $derived.by(() => {
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
if (settingsStore.filterHoursEnabled) {
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
return allEvents.filter((event) => {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event overlaps with visible range
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
});
}
return allEvents;
});
// Get events that are completely outside the visible time range
let overflowEvents = $derived.by(() => {
if (!settingsStore.filterHoursEnabled) {
return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] };
}
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
const before: CalendarEvent[] = [];
const after: CalendarEvent[] = [];
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
for (const event of allEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event ends before visible range starts
if (eventEndMinutes <= visibleStartMinutes) {
before.push(event);
}
// Event starts after visible range ends
else if (eventStartMinutes >= visibleEndMinutes) {
after.push(event);
}
}
return { before, after };
});
let allDayEvents = $derived(
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
@ -813,6 +867,39 @@
/>
{/each}
<!-- Overflow indicators for events outside visible time range -->
{#if overflowEvents.before.length > 0}
<div class="overflow-indicator top" title="{overflowEvents.before.length} Termin(e) früher">
{#each overflowEvents.before as event}
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
></div>
{/each}
</div>
{/if}
{#if overflowEvents.after.length > 0}
<div
class="overflow-indicator bottom"
title="{overflowEvents.after.length} Termin(e) später"
>
{#each overflowEvents.after as event}
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
></div>
{/each}
</div>
{/if}
<!-- Current time indicator -->
{#if isToday(viewStore.currentDate)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
@ -1106,4 +1193,39 @@
.hour-slot:hover {
background: hsl(var(--color-muted) / 0.2);
}
/* Overflow indicators for events outside visible time range */
.overflow-indicator {
position: absolute;
left: 4px;
right: 4px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 5;
padding: 2px;
}
.overflow-indicator.top {
top: 0;
}
.overflow-indicator.bottom {
bottom: 0;
}
.overflow-line {
height: 3px;
border-radius: 2px;
opacity: 0.7;
cursor: pointer;
transition:
opacity 0.15s ease,
height 0.15s ease;
}
.overflow-line:hover {
opacity: 1;
height: 5px;
}
</style>

View file

@ -18,9 +18,6 @@
isToday,
isSameDay,
isWeekend,
setYear,
setMonth,
setDate,
getHours,
getMinutes,
differenceInMinutes,

View file

@ -2,6 +2,16 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
// Portal action - moves element to body to escape stacking contexts
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
interface Props {
direction?: 'up' | 'down';
embedded?: boolean;
@ -80,20 +90,23 @@
</button>
{#if isOpen}
<!-- Backdrop -->
<!-- Backdrop - portal to body -->
<button
use:portal
class="menu-backdrop"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
aria-label="Close dropdown"
style="z-index: 99990;"
></button>
<!-- Dropdown -->
<!-- Dropdown - portal to body -->
<div
use:portal
class="dropdown-container"
class:dropdown-up={direction === 'up'}
class:dropdown-down={direction === 'down'}
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 99991;"
>
<div class="dropdown-header">
<span class="header-title">Kalender</span>

View file

@ -175,8 +175,8 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<!-- Header -->
<div class="modal-header">

View file

@ -104,10 +104,12 @@
}
</script>
<button
type="button"
onclick={handleClick}
disabled={!isAvailable}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={isAvailable ? handleClick : undefined}
role="button"
tabindex={isAvailable ? 0 : -1}
aria-disabled={!isAvailable}
class="relative w-full p-4 rounded-xl border-2 transition-all text-left
{isActive
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
@ -161,12 +163,8 @@
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
{#if definition.icon && themeIcons[definition.icon as keyof typeof themeIcons]}
<svelte:component
this={themeIcons[definition.icon as keyof typeof themeIcons]}
size={20}
weight="duotone"
class="text-primary"
/>
{@const IconComponent = themeIcons[definition.icon as keyof typeof themeIcons]}
<IconComponent size={20} weight="duotone" class="text-primary" />
{/if}
<span class="font-semibold text-foreground">{definition.label}</span>
</div>
@ -207,4 +205,4 @@
{t.comingSoon}
</div>
{/if}
</button>
</div>

View file

@ -1,4 +1,14 @@
<script lang="ts">
// Portal action - moves element to body to escape stacking contexts
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
interface Props {
/** Start hour (0-23) */
startHour: number;
@ -14,6 +24,12 @@
labelFormat?: 'range' | 'icon';
/** Embedded mode - no background/border, for use inside a parent bar */
embedded?: boolean;
/** Toggle mode - click toggles active state, right-click/long-press opens dropdown */
toggleMode?: boolean;
/** Whether the filter is active (only used in toggleMode) */
active?: boolean;
/** Called when toggle state changes (only used in toggleMode) */
onToggle?: () => void;
}
let {
@ -24,13 +40,18 @@
direction = 'down',
labelFormat = 'range',
embedded = false,
toggleMode = false,
active = false,
onToggle,
}: Props = $props();
let isOpen = $state(false);
let triggerButton: HTMLButtonElement;
let dropdownPosition = $state({ top: 0, left: 0 });
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let isLongPress = $state(false);
function toggle() {
function openDropdown() {
if (triggerButton) {
const rect = triggerButton.getBoundingClientRect();
if (direction === 'down') {
@ -45,7 +66,51 @@
};
}
}
isOpen = !isOpen;
isOpen = true;
}
function handleClick() {
if (toggleMode) {
// In toggle mode, click toggles the filter
if (!isLongPress) {
onToggle?.();
}
isLongPress = false;
} else {
// Normal mode - click opens dropdown
openDropdown();
}
}
function handleContextMenu(e: MouseEvent) {
if (toggleMode) {
e.preventDefault();
openDropdown();
}
}
function handlePointerDown() {
if (toggleMode) {
isLongPress = false;
longPressTimer = setTimeout(() => {
isLongPress = true;
openDropdown();
}, 500);
}
}
function handlePointerUp() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handlePointerLeave() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function close() {
@ -78,12 +143,17 @@
<div class="pill-time-selector">
<button
bind:this={triggerButton}
onclick={toggle}
onclick={handleClick}
oncontextmenu={handleContextMenu}
onpointerdown={handlePointerDown}
onpointerup={handlePointerUp}
onpointerleave={handlePointerLeave}
class="trigger-button"
class:pill={!embedded}
class:glass-pill={!embedded}
class:embedded-btn={embedded}
title="Zeitbereich auswählen"
class:active={toggleMode && active}
title={toggleMode ? 'Klick: Ein/Aus | Rechtsklick: Zeitbereich' : 'Zeitbereich auswählen'}
>
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -96,35 +166,42 @@
{#if label}
<span class="pill-label">{label}</span>
{/if}
<svg
class="chevron-icon"
class:rotated={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{#if !toggleMode}
<svg
class="chevron-icon"
class:rotated={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{/if}
</button>
{#if isOpen}
<!-- Backdrop - portal to body -->
<button
use:portal
class="backdrop"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
aria-label="Close"
style="z-index: 99990;"
></button>
<!-- Dropdown - portal to body -->
<div
use:portal
class="dropdown glass-dropdown"
class:dropdown-up={direction === 'up'}
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 99991;"
>
<div class="dropdown-header">Zeitbereich</div>
<div class="time-selectors">
<div class="time-column">
<label class="column-label">Von</label>
<span class="column-label">Von</span>
<div class="hour-list">
{#each startHours as hour}
<button
@ -143,7 +220,7 @@
<div class="time-divider"></div>
<div class="time-column">
<label class="column-label">Bis</label>
<span class="column-label">Bis</span>
<div class="hour-list">
{#each endHours as hour}
<button
@ -260,6 +337,29 @@
background: rgba(255, 255, 255, 0.1);
}
/* Active state for toggle mode */
.embedded-btn.active {
background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 15%, transparent 85%);
color: var(--color-primary-500, #3b82f6);
}
:global(.dark) .embedded-btn.active {
background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 25%, transparent 75%);
color: var(--color-primary-400, #60a5fa);
}
.glass-pill.active {
background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 15%, white 85%);
border-color: var(--color-primary-500, #3b82f6);
color: var(--color-primary-500, #3b82f6);
}
:global(.dark) .glass-pill.active {
background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 30%, transparent 70%);
border-color: var(--color-primary-400, #60a5fa);
color: var(--color-primary-400, #60a5fa);
}
.chevron-icon {
width: 0.75rem;
height: 0.75rem;