File size: 9,101 Bytes
a04c5e9 2d5923d a04c5e9 161c8f7 1730528 2d5923d 161c8f7 1730528 4331e77 f17bf16 4331e77 1d6db01 9f870c5 2d5923d 21b8785 f17bf16 161c8f7 21b8785 161c8f7 4331e77 1d6db01 4331e77 f0aa0e8 1730528 161c8f7 1730528 f17bf16 21b8785 161c8f7 1730528 f17bf16 1730528 21b8785 f0aa0e8 2d5923d 95e2b9b 2d5923d 3bf15b0 2d5923d be83bb2 2d5923d be83bb2 2d5923d 95e2b9b 2d5923d 95e2b9b 2d5923d 95e2b9b 2d5923d 95e2b9b 2d5923d bb5b6b1 2d5923d 161c8f7 43fd2d2 4331e77 43fd2d2 161c8f7 aac3c77 f17bf16 161c8f7 4331e77 161c8f7 977b7da 053849e 043d48d 053849e 1730528 4331e77 9f870c5 4331e77 161c8f7 4f861c3 2d5923d f0aa0e8 2d5923d f0aa0e8 f17bf16 2d5923d 34f6218 4331e77 f17bf16 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
<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>
|