feat(calendar): make toolbar content horizontally scrollable on mobile

- Added overflow-x: auto with hidden scrollbar
- Max-width constraint to prevent overflow
- Smooth touch scrolling for mobile devices

🤖 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-13 13:57:28 +01:00
parent 5ebdc35204
commit e8ec273355
7 changed files with 314 additions and 4 deletions

View file

@ -153,6 +153,15 @@
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.08);
border-radius: 1rem;
white-space: nowrap;
max-width: calc(100vw - 2rem);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.toolbar-content::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
:global(.dark) .toolbar-content {

View file

@ -293,8 +293,8 @@
.date-strip-wrapper {
position: fixed;
bottom: calc(140px + env(safe-area-inset-bottom, 0px)); /* Above InputBar + PillNav */
left: 1rem;
right: 1rem;
left: 0;
right: 0;
z-index: 48;
display: flex;
flex-direction: column;
@ -513,8 +513,8 @@
/* Responsive */
@media (max-width: 640px) {
.date-strip-wrapper {
left: 0.5rem;
right: 0.5rem;
left: 0;
right: 0;
}
.date-strip-container {

View file

@ -0,0 +1,45 @@
/**
* Event Context Menu Store - Manages context menu state for calendar events
*/
import type { CalendarEvent } from '@calendar/shared';
// State
let visible = $state(false);
let x = $state(0);
let y = $state(0);
let targetEvent = $state<CalendarEvent | null>(null);
export const eventContextMenuStore = {
// Getters
get visible() {
return visible;
},
get x() {
return x;
},
get y() {
return y;
},
get targetEvent() {
return targetEvent;
},
/**
* Show the context menu for an event
*/
show(event: CalendarEvent, clientX: number, clientY: number) {
targetEvent = event;
x = clientX;
y = clientY;
visible = true;
},
/**
* Hide the context menu
*/
hide() {
visible = false;
targetEvent = null;
},
};

View file

@ -0,0 +1,208 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import type { ContextMenuItem } from './types';
interface Props {
visible: boolean;
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
onSelect?: (item: ContextMenuItem) => void;
}
let { visible, x, y, items, onClose, onSelect }: Props = $props();
let menuElement = $state<HTMLElement | null>(null);
let adjustedX = $state(x);
let adjustedY = $state(y);
// Adjust position to keep menu within viewport
$effect(() => {
if (visible && menuElement) {
const rect = menuElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust X if menu would overflow right
if (x + rect.width > viewportWidth - 10) {
adjustedX = x - rect.width;
} else {
adjustedX = x;
}
// Adjust Y if menu would overflow bottom
if (y + rect.height > viewportHeight - 10) {
adjustedY = y - rect.height;
} else {
adjustedY = y;
}
}
});
function handleItemClick(item: ContextMenuItem) {
if (item.disabled) return;
if (item.action) {
item.action();
}
if (onSelect) {
onSelect(item);
}
onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
onMount(() => {
// Close on click outside
const handleClickOutside = (e: MouseEvent) => {
if (menuElement && !menuElement.contains(e.target as Node)) {
onClose();
}
};
// Close on scroll
const handleScroll = () => {
onClose();
};
window.addEventListener('click', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('keydown', handleKeyDown);
};
});
</script>
{#if visible}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={menuElement}
class="context-menu"
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
tabindex="-1"
transition:fly={{ duration: 150, y: -8 }}
onclick={(e) => e.stopPropagation()}
oncontextmenu={(e) => e.preventDefault()}
onkeydown={handleKeyDown}
>
{#each items as item, index}
{#if item.type === 'divider'}
<div class="divider"></div>
{:else}
<button
class="menu-item"
class:disabled={item.disabled}
class:danger={item.variant === 'danger'}
onclick={() => handleItemClick(item)}
role="menuitem"
disabled={item.disabled}
>
{#if item.icon}
<span class="item-icon">
{@render item.icon()}
</span>
{/if}
<span class="item-label">{item.label}</span>
{#if item.shortcut}
<span class="item-shortcut">{item.shortcut}</span>
{/if}
</button>
{/if}
{/each}
</div>
{/if}
<style>
.context-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
max-width: 280px;
padding: 0.375rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
box-shadow:
0 10px 38px -10px rgba(0, 0, 0, 0.35),
0 10px 20px -15px rgba(0, 0, 0, 0.2);
}
.menu-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
text-align: left;
transition: background-color 100ms ease;
}
.menu-item:hover:not(.disabled) {
background: hsl(var(--color-muted));
}
.menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.danger {
color: hsl(var(--color-error));
}
.menu-item.danger:hover:not(.disabled) {
background: hsl(var(--color-error) / 0.1);
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: hsl(var(--color-muted-foreground));
}
.menu-item.danger .item-icon {
color: hsl(var(--color-error));
}
.item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-shortcut {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin-left: auto;
padding-left: 1rem;
}
.divider {
height: 1px;
margin: 0.375rem 0.5rem;
background: hsl(var(--color-border));
}
</style>

View file

@ -0,0 +1,3 @@
export { default as ContextMenu } from './ContextMenu.svelte';
export type { ContextMenuItem, ContextMenuState } from './types';
export { createContextMenuState } from './types';

View file

@ -0,0 +1,41 @@
import type { Snippet } from 'svelte';
export interface ContextMenuItem {
/** Unique identifier for the item */
id: string;
/** Display label */
label: string;
/** Icon snippet to render */
icon?: Snippet;
/** Keyboard shortcut hint */
shortcut?: string;
/** Whether the item is disabled */
disabled?: boolean;
/** Visual variant */
variant?: 'default' | 'danger';
/** Item type - use 'divider' for separator */
type?: 'item' | 'divider';
/** Action to perform when clicked */
action?: () => void;
/** Additional data attached to the item */
data?: unknown;
}
export interface ContextMenuState<T = unknown> {
visible: boolean;
x: number;
y: number;
target: T | null;
}
/**
* Creates a context menu state object
*/
export function createContextMenuState<T = unknown>(): ContextMenuState<T> {
return {
visible: false,
x: 0,
y: 0,
target: null,
};
}

View file

@ -143,3 +143,7 @@ export type {
DonutSegment,
ProgressItem,
} from './charts';
// Context Menu
export { ContextMenu, createContextMenuState } from './context-menu';
export type { ContextMenuItem, ContextMenuState } from './context-menu';