mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 22:59:40 +02:00
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:
parent
f2ac3e245e
commit
448cfb9010
8 changed files with 449 additions and 149 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@
|
|||
isToday,
|
||||
isSameDay,
|
||||
isWeekend,
|
||||
setYear,
|
||||
setMonth,
|
||||
setDate,
|
||||
getHours,
|
||||
getMinutes,
|
||||
differenceInMinutes,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue