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

@ -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;