mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 14:59:39 +02:00
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:
parent
5ebdc35204
commit
e8ec273355
7 changed files with 314 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
208
packages/shared-ui/src/context-menu/ContextMenu.svelte
Normal file
208
packages/shared-ui/src/context-menu/ContextMenu.svelte
Normal 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>
|
||||
3
packages/shared-ui/src/context-menu/index.ts
Normal file
3
packages/shared-ui/src/context-menu/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||
export type { ContextMenuItem, ContextMenuState } from './types';
|
||||
export { createContextMenuState } from './types';
|
||||
41
packages/shared-ui/src/context-menu/types.ts
Normal file
41
packages/shared-ui/src/context-menu/types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue