Skip to content

Commit d6e48c6

Browse files
authored
Mobile UI update (#2004)
* Adjust button and menu item sizes for better UI consistency Updated various button and dropdown menu item classes in ChatInput.svelte and ChatWindow.svelte to use larger default sizes (size-8, h-9) with responsive adjustments for small screens (sm:size-7, sm:h-8). This improves visual consistency and touch target accessibility across the chat interface. * Replace custom SVG icons with Lucide icons Swapped out CarbonAdd and custom SVG icons for Lucide's IconPlus and IconArrowUp in ChatInput and ChatWindow components to standardize icon usage and improve maintainability. * Update model list styles for responsiveness Replaced fixed height/width classes with responsive 'size' utility classes for images and icons. Adjusted text size for model names on small screens to improve mobile usability. * Add swipe gesture support to MobileNav Introduces swipe gesture detection for opening and closing the mobile navigation drawer. Swiping right from the left edge opens the nav, and swiping left closes it, improving mobile usability. * Adjust model card spacing and text size for responsiveness Reduced gap and padding for model cards on smaller screens and updated description text size to improve mobile responsiveness. Larger screens retain previous spacing and font size. * Adjust IconPlus size for responsive design Added 'sm:text-sm' class to IconPlus in ChatInput to improve icon scaling on small screens. * Update +page.svelte * Improve mobile drawer swipe gesture with live feedback Refactors the mobile navigation drawer to provide live feedback during swipe gestures, including velocity and position-based snapping, direction locking, and improved overlay handling. This enhances the user experience by making the drawer feel more responsive and intuitive on touch devices.
1 parent 94f3169 commit d6e48c6

File tree

4 files changed

+178
-52
lines changed

4 files changed

+178
-52
lines changed

src/lib/components/MobileNav.svelte

Lines changed: 156 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
export function closeMobileNav() {
55
isOpen = false;
66
}
7+
8+
export function openMobileNav() {
9+
isOpen = true;
10+
}
711
</script>
812

913
<script lang="ts">
1014
import { browser } from "$app/environment";
1115
import { beforeNavigate } from "$app/navigation";
16+
import { onMount, onDestroy } from "svelte";
1217
import { base } from "$app/paths";
1318
import { page } from "$app/state";
1419
import IconNew from "$lib/components/icons/IconNew.svelte";
@@ -18,6 +23,7 @@
1823
import { shareModal } from "$lib/stores/shareModal";
1924
import { loading } from "$lib/stores/loading";
2025
import { requireAuthUser } from "$lib/utils/auth";
26+
2127
interface Props {
2228
title: string | undefined;
2329
children?: import("svelte").Snippet;
@@ -39,16 +45,6 @@
3945
// Define the width for the drawer (less than 100% to create the gap)
4046
const drawerWidthPercentage = 85;
4147
42-
const tween = Spring.of(
43-
() => {
44-
if (isOpen) {
45-
return 0 as number;
46-
}
47-
return -100 as number;
48-
},
49-
{ stiffness: 0.2, damping: 0.8 }
50-
);
51-
5248
$effect(() => {
5349
title ??= "New Chat";
5450
});
@@ -72,6 +68,149 @@
7268
function closeDrawer() {
7369
isOpen = false;
7470
}
71+
72+
// Swipe gesture support for opening/closing the nav with live feedback
73+
// Thresholds from vaul drawer library
74+
const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction
75+
const CLOSE_THRESHOLD = 0.25; // 25% position threshold
76+
const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction
77+
78+
let touchstart: Touch | null = null;
79+
let dragStartTime: number = 0;
80+
let isDragging = $state(false);
81+
let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)
82+
let dragStartedOpen = false;
83+
84+
// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll
85+
let directionLock: "horizontal" | "vertical" | null = null;
86+
let potentialDrag = false;
87+
88+
// Spring target: follows dragOffset during drag, follows isOpen after drag ends
89+
const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);
90+
const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });
91+
92+
function onTouchStart(e: TouchEvent) {
93+
const touch = e.changedTouches[0];
94+
touchstart = touch;
95+
dragStartTime = Date.now();
96+
directionLock = null;
97+
98+
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
99+
const touchOnDrawer = isOpen && touch.clientX < drawerWidth;
100+
101+
// Potential drag scenarios - never start isDragging until direction is locked
102+
// Exception: overlay tap (no scroll content, so no direction conflict)
103+
if (!isOpen && touch.clientX < 40) {
104+
// Opening gesture - wait for direction lock before starting drag
105+
potentialDrag = true;
106+
dragStartedOpen = false;
107+
} else if (isOpen && !touchOnDrawer) {
108+
// Touch on overlay - can start immediately (no scroll conflict)
109+
potentialDrag = true;
110+
isDragging = true;
111+
dragStartedOpen = true;
112+
dragOffset = 0;
113+
directionLock = "horizontal";
114+
} else if (isOpen && touchOnDrawer) {
115+
// Touch on drawer content - wait for direction lock
116+
potentialDrag = true;
117+
dragStartedOpen = true;
118+
}
119+
}
120+
121+
function onTouchMove(e: TouchEvent) {
122+
if (!touchstart || !potentialDrag) return;
123+
124+
const touch = e.changedTouches[0];
125+
const deltaX = touch.clientX - touchstart.clientX;
126+
const deltaY = touch.clientY - touchstart.clientY;
127+
128+
// Determine direction lock if not yet decided
129+
if (directionLock === null) {
130+
const absX = Math.abs(deltaX);
131+
const absY = Math.abs(deltaY);
132+
133+
if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {
134+
if (absX > absY) {
135+
// Horizontal movement - commit to drawer drag
136+
directionLock = "horizontal";
137+
isDragging = true;
138+
dragOffset = dragStartedOpen ? 0 : -100;
139+
} else {
140+
// Vertical movement - abort potential drag, let content scroll
141+
directionLock = "vertical";
142+
potentialDrag = false;
143+
return;
144+
}
145+
} else {
146+
return;
147+
}
148+
}
149+
150+
if (directionLock !== "horizontal") return;
151+
152+
const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
153+
154+
if (dragStartedOpen) {
155+
dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));
156+
} else {
157+
dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));
158+
}
159+
}
160+
161+
function onTouchEnd(e: TouchEvent) {
162+
if (!potentialDrag) return;
163+
164+
if (!isDragging || !touchstart) {
165+
resetDragState();
166+
return;
167+
}
168+
169+
const touch = e.changedTouches[0];
170+
const timeTaken = Date.now() - dragStartTime;
171+
const distMoved = touch.clientX - touchstart.clientX;
172+
const velocity = Math.abs(distMoved) / timeTaken;
173+
174+
// Determine snap direction based on velocity first, then position
175+
if (velocity > VELOCITY_THRESHOLD) {
176+
isOpen = distMoved > 0;
177+
} else {
178+
const openThreshold = -100 + CLOSE_THRESHOLD * 100;
179+
isOpen = dragOffset > openThreshold;
180+
}
181+
182+
resetDragState();
183+
}
184+
185+
function onTouchCancel() {
186+
if (isDragging) {
187+
isOpen = dragStartedOpen;
188+
}
189+
resetDragState();
190+
}
191+
192+
function resetDragState() {
193+
isDragging = false;
194+
potentialDrag = false;
195+
touchstart = null;
196+
directionLock = null;
197+
}
198+
199+
onMount(() => {
200+
window.addEventListener("touchstart", onTouchStart, { passive: true });
201+
window.addEventListener("touchmove", onTouchMove, { passive: true });
202+
window.addEventListener("touchend", onTouchEnd, { passive: true });
203+
window.addEventListener("touchcancel", onTouchCancel, { passive: true });
204+
});
205+
206+
onDestroy(() => {
207+
if (browser) {
208+
window.removeEventListener("touchstart", onTouchStart);
209+
window.removeEventListener("touchmove", onTouchMove);
210+
window.removeEventListener("touchend", onTouchEnd);
211+
window.removeEventListener("touchcancel", onTouchCancel);
212+
}
213+
});
75214
</script>
76215

77216
<nav
@@ -120,23 +259,22 @@
120259
</div>
121260
</nav>
122261

123-
<!-- Mobile drawer overlay - shows when drawer is open -->
124-
{#if isOpen}
262+
<!-- Mobile drawer overlay - shows when drawer is open or dragging -->
263+
{#if isOpen || isDragging}
125264
<button
126265
type="button"
127266
class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden"
128-
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))};"
267+
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;"
129268
onclick={closeDrawer}
130269
aria-label="Close mobile navigation"
131270
></button>
132271
{/if}
133272

134273
<nav
135-
style="transform: translateX({Math.max(
136-
-100,
137-
Math.min(0, tween.current)
138-
)}%); width: {drawerWidthPercentage}%;"
139-
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen}
274+
style="transform: translateX({isDragging
275+
? dragOffset
276+
: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;"
277+
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}
140278
class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1
141279
grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden"
142280
>

src/lib/components/chat/ChatInput.svelte

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { afterNavigate } from "$app/navigation";
55
66
import { DropdownMenu } from "bits-ui";
7-
import CarbonAdd from "~icons/carbon/add";
7+
import IconPlus from "~icons/lucide/plus";
88
import CarbonImage from "~icons/carbon/image";
99
import CarbonDocument from "~icons/carbon/document";
1010
import CarbonUpload from "~icons/carbon/upload";
@@ -269,11 +269,11 @@
269269
}}
270270
>
271271
<DropdownMenu.Trigger
272-
class="btn size-7 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600"
272+
class="btn size-8 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600 sm:size-7"
273273
disabled={loading}
274274
aria-label="Add attachment"
275275
>
276-
<CarbonAdd class="text-base" />
276+
<IconPlus class="text-base sm:text-sm" />
277277
</DropdownMenu.Trigger>
278278
<DropdownMenu.Portal>
279279
<DropdownMenu.Content
@@ -287,7 +287,7 @@
287287
>
288288
{#if modelIsMultimodal}
289289
<DropdownMenu.Item
290-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
290+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
291291
onSelect={() => openFilePickerImage()}
292292
>
293293
<CarbonImage class="size-4 opacity-90 dark:opacity-80" />
@@ -297,7 +297,7 @@
297297

298298
<DropdownMenu.Sub>
299299
<DropdownMenu.SubTrigger
300-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10"
300+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
301301
>
302302
<div class="flex items-center gap-1">
303303
<CarbonDocument class="size-4 opacity-90 dark:opacity-80" />
@@ -315,14 +315,14 @@
315315
interactOutsideBehavior="defer-otherwise-close"
316316
>
317317
<DropdownMenu.Item
318-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
318+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
319319
onSelect={() => openFilePickerText()}
320320
>
321321
<CarbonUpload class="size-4 opacity-90 dark:opacity-80" />
322322
Upload from device
323323
</DropdownMenu.Item>
324324
<DropdownMenu.Item
325-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
325+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
326326
onSelect={() => (isUrlModalOpen = true)}
327327
>
328328
<CarbonLink class="size-4 opacity-90 dark:opacity-80" />
@@ -334,7 +334,7 @@
334334
<!-- MCP Servers submenu -->
335335
<DropdownMenu.Sub>
336336
<DropdownMenu.SubTrigger
337-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10"
337+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
338338
>
339339
<div class="flex items-center gap-1">
340340
<IconMCP classNames="size-4 opacity-90 dark:opacity-80" />
@@ -389,7 +389,7 @@
389389
<DropdownMenu.Separator class="my-1 h-px bg-gray-200 dark:bg-gray-700/60" />
390390
{/if}
391391
<DropdownMenu.Item
392-
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
392+
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
393393
onSelect={() => (isMcpManagerOpen = true)}
394394
>
395395
Manage MCP Servers
@@ -402,7 +402,7 @@
402402

403403
{#if $enabledServersCount > 0}
404404
<div
405-
class="ml-2 inline-flex h-7 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400"
405+
class="ml-2 inline-flex h-8 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 sm:h-7"
406406
class:grayscale={!modelSupportsTools}
407407
class:opacity-60={!modelSupportsTools}
408408
class:cursor-help={!modelSupportsTools}

src/lib/components/chat/ChatWindow.svelte

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import IconOmni from "$lib/components/icons/IconOmni.svelte";
66
import CarbonCaretDown from "~icons/carbon/caret-down";
77
import CarbonDirectionRight from "~icons/carbon/direction-right-01";
8+
import IconArrowUp from "~icons/lucide/arrow-up";
89
910
import ChatInput from "./ChatInput.svelte";
1011
import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
@@ -559,11 +560,11 @@
559560
<StopGeneratingBtn
560561
onClick={() => onstop?.()}
561562
showBorder={true}
562-
classNames="absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none dark:border-transparent dark:bg-gray-600 dark:text-white"
563+
classNames="absolute bottom-2 right-2 size-8 sm:size-7 self-end rounded-full border bg-white text-black shadow transition-none dark:border-transparent dark:bg-gray-600 dark:text-white"
563564
/>
564565
{:else}
565566
<button
566-
class="btn absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!draft ||
567+
class="btn absolute bottom-2 right-2 size-8 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black sm:size-7 {!draft ||
567568
isReadOnly
568569
? ''
569570
: '!bg-black !text-white dark:!bg-white dark:!text-black'}"
@@ -572,20 +573,7 @@
572573
aria-label="Send message"
573574
name="submit"
574575
>
575-
<svg
576-
width="1em"
577-
height="1em"
578-
viewBox="0 0 32 32"
579-
fill="none"
580-
xmlns="http://www.w3.org/2000/svg"
581-
>
582-
<path
583-
fill-rule="evenodd"
584-
clip-rule="evenodd"
585-
d="M17.0606 4.23197C16.4748 3.64618 15.525 3.64618 14.9393 4.23197L5.68412 13.4871C5.09833 14.0729 5.09833 15.0226 5.68412 15.6084C6.2699 16.1942 7.21965 16.1942 7.80544 15.6084L14.4999 8.91395V26.7074C14.4999 27.5359 15.1715 28.2074 15.9999 28.2074C16.8283 28.2074 17.4999 27.5359 17.4999 26.7074V8.91395L24.1944 15.6084C24.7802 16.1942 25.7299 16.1942 26.3157 15.6084C26.9015 15.0226 26.9015 14.0729 26.3157 13.4871L17.0606 4.23197Z"
586-
fill="currentColor"
587-
/>
588-
</svg>
576+
<IconArrowUp />
589577
</button>
590578
{/if}
591579
</div>

0 commit comments

Comments
 (0)