feat(calendar): unify view switcher into new ViewsBar component

- Add new ViewsBar component with same design as InputBar
- Position ViewsBar next to InputBar (left on desktop, above on mobile)
- Remove view switcher from PillNavigation prependElements
- Remove PillViewSwitcher from CalendarToolbarContent
- Clean up unused imports and code from layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-16 00:07:49 +01:00
parent 9bb8d3a21f
commit 32f84678f9
4 changed files with 229 additions and 170 deletions

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { CalendarViewType } from '@calendar/shared';
import {
PillToolbarButton,
PillToolbarDivider,
PillTimeRangeSelector,
PillViewSwitcher,
} from '@manacore/shared-ui';
import PillCalendarSelector from './PillCalendarSelector.svelte';
@ -16,43 +13,6 @@
let { vertical = false }: Props = $props();
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Views to show in selector
const visibleViews: CalendarViewType[] = [
'day',
'5day',
'week',
'10day',
'14day',
'month',
'year',
'agenda',
];
// 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);
@ -61,10 +21,6 @@
function handleEndHourChange(hour: number) {
settingsStore.set('dayEndHour', hour);
}
function handleViewChange(type: string) {
viewStore.setViewType(type as CalendarViewType);
}
</script>
<div class="toolbar-content" class:vertical>
@ -97,18 +53,6 @@
onToggle={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
labelFormat="range"
/>
{#if !vertical}
<PillToolbarDivider />
{/if}
<!-- View selector -->
<PillViewSwitcher
options={viewOptions}
value={viewStore.viewType}
onChange={handleViewChange}
embedded={true}
/>
</div>
<style>
@ -134,40 +78,6 @@
text-align: left;
}
/* PillViewSwitcher in vertical mode */
.toolbar-content.vertical :global(.pill-view-switcher) {
flex-direction: column;
gap: 0.25rem;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
width: 100%;
}
/* Hide the sliding indicator in vertical mode */
.toolbar-content.vertical :global(.pill-view-switcher .sliding-indicator) {
display: none;
}
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn) {
width: 100%;
justify-content: flex-start;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
background: transparent;
border: 1px solid transparent;
}
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) {
background: hsl(var(--color-foreground) / 0.05);
}
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) {
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.25);
}
/* PillTimeRangeSelector in vertical mode */
.toolbar-content.vertical :global(.pill-time-range-selector),
.toolbar-content.vertical :global(.pill-dropdown) {

View file

@ -67,7 +67,7 @@
}
}
/* Mobile: Position in row above InputBar, left of ViewModePill */
/* Mobile: Position in row above InputBar, left of ViewsBar */
/* InputBar is at bottom: 70px (above PillNav), so controls go above that */
.datestrip-fab-container.mobile {
/* Above PillNav (70px) + InputBar (72px) + gap (8px), to the left of center */

View file

@ -0,0 +1,211 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { CalendarViewType } from '@calendar/shared';
import ViewModePillContextMenu from './ViewModePillContextMenu.svelte';
interface Props {
/** Bottom offset from viewport bottom (default: '70px') */
bottomOffset?: string;
}
let { bottomOffset = '70px' }: Props = $props();
let contextMenu: ViewModePillContextMenu;
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
function handleViewClick(view: CalendarViewType) {
viewStore.setViewType(view);
}
// View labels (numbers for day views, letters for others)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'L',
custom: '', // Will be set dynamically
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings
let enabledViews = $derived(settingsStore.quickViewPillViews);
// Get label for a view (dynamic for custom)
function getViewLabel(view: CalendarViewType): string {
if (view === 'custom') {
return String(settingsStore.customDayCount);
}
return viewLabels[view];
}
// Get title for a view (dynamic for custom)
function getViewTitle(view: CalendarViewType): string {
if (view === 'custom') {
return `${settingsStore.customDayCount}-Tage-Ansicht`;
}
return viewTitles[view];
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="views-bar" style="--bottom-offset: {bottomOffset}" oncontextmenu={handleContextMenu}>
<div class="views-container">
<div class="views-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
</div>
<div class="views-buttons">
{#each enabledViews as view}
<button
type="button"
class="view-btn"
class:active={viewStore.viewType === view}
onclick={() => handleViewClick(view)}
title={getViewTitle(view)}
>
{getViewLabel(view)}
</button>
{/each}
</div>
</div>
</div>
<ViewModePillContextMenu bind:this={contextMenu} />
<style>
.views-bar {
position: fixed;
bottom: calc(var(--bottom-offset, 70px) + env(safe-area-inset-bottom, 0px));
z-index: 90;
padding: 0.75rem 0;
pointer-events: none;
height: 72px;
transition: bottom 0.3s ease;
display: flex;
/* Desktop: Position left of InputBar (InputBar has max-width 700px, centered) */
/* ViewsBar ends at left edge of InputBar with a gap */
right: calc(50% + 350px + 8px); /* InputBar center + half width + gap */
left: auto;
}
.views-container {
pointer-events: auto;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
box-shadow:
0 4px 6px -1px hsl(var(--color-foreground) / 0.1),
0 2px 4px -1px hsl(var(--color-foreground) / 0.06);
transition: all 0.2s ease;
height: 54px;
}
.views-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.views-icon svg {
width: 100%;
height: 100%;
}
.views-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s ease;
}
.view-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
/* Tablet: Position left with fixed margin */
@media (max-width: 900px) {
.views-bar {
right: auto;
left: 1rem;
}
}
/* Mobile: Center above InputBar */
@media (max-width: 640px) {
.views-bar {
/* Position above the InputBar - centered */
bottom: calc(var(--bottom-offset, 70px) + 72px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
justify-content: center;
padding: 0.75rem 1rem;
}
}
</style>

View file

@ -57,14 +57,13 @@
import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte';
import DateStrip from '$lib/components/calendar/DateStrip.svelte';
import DateStripFab from '$lib/components/calendar/DateStripFab.svelte';
import ViewsBar from '$lib/components/calendar/ViewsBar.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte';
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte';
import VoiceRecordingModal from '$lib/components/voice/VoiceRecordingModal.svelte';
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import type { CalendarViewType } from '@calendar/shared';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -287,78 +286,6 @@
onChange: handleTabChange,
});
// View switcher context menu
let viewContextMenu: ViewModePillContextMenu;
function handleViewContextMenu(x: number, y: number) {
viewContextMenu?.show(x, y);
}
// View labels for tabs (numbers for day views, letters for others)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'L',
custom: '', // Will be set dynamically
};
// View titles for tooltips
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings
let enabledViews = $derived(settingsStore.quickViewPillViews);
// Get label for a view (dynamic for custom)
function getViewLabel(view: CalendarViewType): string {
if (view === 'custom') {
return String(settingsStore.customDayCount);
}
return viewLabels[view];
}
// Handle view change
function handleViewChange(id: string) {
viewStore.setViewType(id as CalendarViewType);
}
// View switcher tab group (only shown on calendar main page)
let viewSwitcherTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: enabledViews.map((view) => ({
id: view,
label: getViewLabel(view),
title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
})),
value: viewStore.viewType,
onChange: handleViewChange,
onContextMenu: handleViewContextMenu,
});
// Tag selector config for PillNavigation
let tagSelectorConfig = $derived<PillTagSelectorConfig>({
type: 'tag-selector',
@ -372,9 +299,10 @@
});
// Prepended elements (tab groups at the start of navigation)
// Note: View switcher moved to ViewsBar component
let prependElements = $derived<PillNavElement[]>(
showCalendarToolbar
? [calendarTasksTabGroup, viewSwitcherTabGroup, { type: 'divider' }, tagSelectorConfig]
? [calendarTasksTabGroup, { type: 'divider' }, tagSelectorConfig]
: [calendarTasksTabGroup]
);
@ -640,6 +568,17 @@
{/if}
{/if}
<!-- Views Bar (only on main calendar page, hidden in immersive mode) -->
{#if showCalendarToolbar && !settingsStore.immersiveModeEnabled}
<ViewsBar
bottomOffset={isMobile
? '70px'
: showCalendarToolbar && !isToolbarCollapsed
? '140px'
: '70px'}
/>
{/if}
<!-- Global Input Bar with Voice Button (hidden via CSS in immersive mode to prevent re-mount focus) -->
<div class="input-bar-wrapper" class:hidden={settingsStore.immersiveModeEnabled}>
<div class="input-bar-row">
@ -655,7 +594,9 @@
createText="Erstellen"
appIcon="calendar"
bottomOffset={isMobile
? '70px'
? showCalendarToolbar
? 'calc(70px + 72px)'
: '70px'
: showCalendarToolbar && !isToolbarCollapsed
? '140px'
: '70px'}
@ -706,9 +647,6 @@
<!-- Global Event Context Menu - rendered at top level for proper z-index -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- View Mode Context Menu -->
<ViewModePillContextMenu bind:this={viewContextMenu} />
<!-- InputBar Help Modal -->
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />