chat-ui / src /lib /components /MobileNav.svelte
victor's picture
victor HF Staff
Update MobileNav.svelte
3bf15b0
<script lang="ts" module>
let isOpen = $state(false);
export function closeMobileNav() {
isOpen = false;
}
export function openMobileNav() {
isOpen = true;
}
</script>
<script lang="ts">
import { browser } from "$app/environment";
import { beforeNavigate } from "$app/navigation";
import { onMount, onDestroy } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/state";
import IconNew from "$lib/components/icons/IconNew.svelte";
import IconShare from "$lib/components/icons/IconShare.svelte";
import IconBurger from "$lib/components/icons/IconBurger.svelte";
import { Spring } from "svelte/motion";
import { shareModal } from "$lib/stores/shareModal";
import { loading } from "$lib/stores/loading";
import { requireAuthUser } from "$lib/utils/auth";
interface Props {
title: string | undefined;
children?: import("svelte").Snippet;
}
let { title = $bindable(), children }: Props = $props();
let closeEl: HTMLButtonElement | undefined = $state();
let openEl: HTMLButtonElement | undefined = $state();
const isHuggingChat = $derived(Boolean(page.data?.publicConfig?.isHuggingChat));
const canShare = $derived(
isHuggingChat &&
!$loading &&
Boolean(page.params?.id) &&
page.route.id?.startsWith("/conversation/")
);
// Define the width for the drawer (less than 100% to create the gap)
const drawerWidthPercentage = 85;
$effect(() => {
title ??= "New Chat";
});
beforeNavigate(() => {
isOpen = false;
});
let shouldFocusClose = $derived(isOpen && closeEl);
let shouldRefocusOpen = $derived(!isOpen && browser && document.activeElement === closeEl);
$effect(() => {
if (shouldFocusClose) {
closeEl?.focus();
} else if (shouldRefocusOpen) {
openEl?.focus();
}
});
// Function to close the drawer when background is tapped
function closeDrawer() {
isOpen = false;
}
// Swipe gesture support for opening/closing the nav with live feedback
// Thresholds from vaul drawer library
const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction
const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction
let touchstart: Touch | null = null;
let lastTouchX: number | null = null;
let dragStartTime: number = 0;
let isDragging = $state(false);
let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)
let dragStartedOpen = false;
// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll
let directionLock: "horizontal" | "vertical" | null = null;
let potentialDrag = false;
// Spring target: follows dragOffset during drag, follows isOpen after drag ends
const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);
const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });
function onTouchStart(e: TouchEvent) {
// Ignore touch events when a modal is open (app is inert)
if (document.getElementById("app")?.hasAttribute("inert")) return;
const touch = e.changedTouches[0];
touchstart = touch;
dragStartTime = Date.now();
directionLock = null;
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
const touchOnDrawer = isOpen && touch.clientX < drawerWidth;
// Check if touch is on an interactive element (don't block taps on buttons/links)
const target = e.target as HTMLElement;
const isInteractive = target.closest("button, a, input, [role='button']");
// Potential drag scenarios - never start isDragging until direction is locked
// Exception: overlay tap (no scroll content, so no direction conflict)
if (!isOpen && touch.clientX < 40) {
// Opening gesture - wait for direction lock before starting drag
// Prevent Safari's back navigation gesture on iOS (but not on interactive elements)
if (!isInteractive) {
e.preventDefault();
}
potentialDrag = true;
dragStartedOpen = false;
} else if (isOpen && !touchOnDrawer) {
// Touch on overlay - can start immediately (no scroll conflict)
potentialDrag = true;
isDragging = true;
dragStartedOpen = true;
dragOffset = 0;
directionLock = "horizontal";
} else if (isOpen && touchOnDrawer) {
// Touch on drawer content - wait for direction lock
potentialDrag = true;
dragStartedOpen = true;
}
}
function onTouchMove(e: TouchEvent) {
if (!touchstart || !potentialDrag) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchstart.clientX;
const deltaY = touch.clientY - touchstart.clientY;
// Determine direction lock if not yet decided
if (directionLock === null) {
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {
if (absX > absY) {
// Horizontal movement - commit to drawer drag
directionLock = "horizontal";
isDragging = true;
dragOffset = dragStartedOpen ? 0 : -100;
} else {
// Vertical movement - abort potential drag, let content scroll
directionLock = "vertical";
potentialDrag = false;
return;
}
} else {
return;
}
}
if (directionLock !== "horizontal") return;
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
if (dragStartedOpen) {
dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));
} else {
dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));
}
lastTouchX = touch.clientX;
}
function onTouchEnd(e: TouchEvent) {
if (!potentialDrag) return;
if (!isDragging || !touchstart) {
resetDragState();
return;
}
const touch = e.changedTouches[0];
const timeTaken = Date.now() - dragStartTime;
const distMoved = touch.clientX - touchstart.clientX;
const velocity = Math.abs(distMoved) / timeTaken;
// Determine snap direction based on velocity first, then final movement direction
if (velocity > VELOCITY_THRESHOLD) {
isOpen = distMoved > 0;
} else {
// For slow drags, use the final movement direction (allows "change of mind")
const finalDirection = lastTouchX !== null ? touch.clientX - lastTouchX : distMoved;
isOpen = finalDirection > 0;
}
resetDragState();
}
function onTouchCancel() {
if (isDragging) {
isOpen = dragStartedOpen;
}
resetDragState();
}
function resetDragState() {
isDragging = false;
potentialDrag = false;
touchstart = null;
lastTouchX = null;
directionLock = null;
}
onMount(() => {
// touchstart needs passive: false to allow preventDefault() for Safari back gesture
window.addEventListener("touchstart", onTouchStart, { passive: false });
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onTouchEnd, { passive: true });
window.addEventListener("touchcancel", onTouchCancel, { passive: true });
});
onDestroy(() => {
if (browser) {
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("touchcancel", onTouchCancel);
}
});
</script>
<nav
class="flex h-12 items-center justify-between rounded-b-xl border-b bg-gray-50 px-3 dark:border-gray-800 dark:bg-gray-800/30 dark:shadow-xl md:hidden"
>
<button
type="button"
class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
onclick={() => (isOpen = true)}
aria-label="Open menu"
bind:this={openEl}><IconBurger /></button
>
<div class="flex h-full items-center justify-center overflow-hidden">
{#if page.params?.id}
<span class="max-w-full truncate px-4 first-letter:uppercase" data-testid="chat-title"
>{title}</span
>
{/if}
</div>
<div class="flex items-center">
{#if isHuggingChat}
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center text-lg"
disabled={!canShare}
onclick={() => {
if (!canShare) return;
shareModal.open();
}}
aria-label="Share conversation"
>
<IconShare classNames={!canShare ? "opacity-40" : ""} />
</button>
{/if}
<a
href="{base}/"
class="flex size-8 shrink-0 items-center justify-center text-lg"
onclick={(e) => {
if (requireAuthUser()) {
e.preventDefault();
}
}}
>
<IconNew />
</a>
</div>
</nav>
<!-- Mobile drawer overlay - shows when drawer is open or dragging -->
{#if isOpen || isDragging}
<button
type="button"
class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden"
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;"
onclick={closeDrawer}
aria-label="Close mobile navigation"
></button>
{/if}
<nav
style="transform: translateX({isDragging
? dragOffset
: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;"
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}
class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1
grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden"
>
{@render children?.()}
</nav>