nsarrazin HF Staff commited on
Commit
6c59df2
·
unverified ·
1 Parent(s): a47d89a

feat(nav): improve mobile navigation with pan gesture to close (#1729)

Browse files

feat(nav): improve mobile navigation with pan gesture to close conv drawer

src/lib/components/MobileNav.svelte CHANGED
@@ -4,33 +4,49 @@
4
  import { base } from "$app/paths";
5
  import { page } from "$app/state";
6
  import IconNew from "$lib/components/icons/IconNew.svelte";
7
- import { createEventDispatcher } from "svelte";
8
- import { fly } from "svelte/transition";
9
  import CarbonClose from "~icons/carbon/close";
10
  import CarbonTextAlignJustify from "~icons/carbon/text-align-justify";
11
- import { swipe, type SwipeCustomEvent } from "svelte-gestures";
12
  interface Props {
13
- isOpen?: boolean;
14
  title: string | undefined;
15
  children?: import("svelte").Snippet;
16
  }
17
 
18
- let { isOpen = false, title = $bindable(), children }: Props = $props();
19
 
20
  let closeEl: HTMLButtonElement | undefined = $state();
21
  let openEl: HTMLButtonElement | undefined = $state();
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  $effect(() => {
24
  title ??= "New Chat";
25
  });
26
 
27
- const dispatch = createEventDispatcher();
28
  beforeNavigate(() => {
29
- dispatch("toggle", false);
30
  });
31
 
32
  let shouldFocusClose = $derived(isOpen && closeEl);
33
  let shouldRefocusOpen = $derived(!isOpen && browser && document.activeElement === closeEl);
 
34
  $effect(() => {
35
  if (shouldFocusClose) {
36
  closeEl?.focus();
@@ -46,7 +62,7 @@
46
  <button
47
  type="button"
48
  class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
49
- onclick={() => dispatch("toggle", true)}
50
  aria-label="Open menu"
51
  bind:this={openEl}><CarbonTextAlignJustify /></button
52
  >
@@ -60,27 +76,61 @@
60
  >
61
  </nav>
62
 
63
- {#if isOpen}
64
- <nav
65
- use:swipe={() => ({ timeframe: 500, minSwipeDistance: 30 })}
66
- onswipe={(ev: SwipeCustomEvent) => {
67
- if (ev.detail.direction === "left") {
68
- dispatch("toggle", false);
69
- }
70
- }}
71
- class="fixed inset-0 z-30 grid max-h-screen grid-cols-1 grid-rows-[auto,1fr,auto,auto] bg-white pt-4 dark:bg-gray-900"
72
- in:fly={{ x: -window.innerWidth, duration: 250 }}
73
- out:fly={{ x: -window.innerWidth, duration: 250 }}
74
- >
75
- {#if page.url.pathname === base + "/"}
76
- <button
77
- type="button"
78
- class="absolute right-0 top-0 z-10 flex size-12 items-center justify-center text-lg"
79
- onclick={() => dispatch("toggle", false)}
80
- aria-label="Close menu"
81
- bind:this={closeEl}><CarbonClose /></button
82
- >
83
- {/if}
84
- {@render children?.()}
85
- </nav>
86
- {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { base } from "$app/paths";
5
  import { page } from "$app/state";
6
  import IconNew from "$lib/components/icons/IconNew.svelte";
7
+ import { Spring } from "svelte/motion";
 
8
  import CarbonClose from "~icons/carbon/close";
9
  import CarbonTextAlignJustify from "~icons/carbon/text-align-justify";
10
+ import { pan, type GestureCustomEvent, type PanCustomEvent } from "svelte-gestures";
11
  interface Props {
 
12
  title: string | undefined;
13
  children?: import("svelte").Snippet;
14
  }
15
 
16
+ let { title = $bindable(), children }: Props = $props();
17
 
18
  let closeEl: HTMLButtonElement | undefined = $state();
19
  let openEl: HTMLButtonElement | undefined = $state();
20
 
21
+ let isOpen = $state(false);
22
+ let panX: number | undefined = $state(undefined);
23
+ let panStart: number | undefined = $state(undefined);
24
+ let panStartTime: number | undefined = undefined;
25
+
26
+ const tween = Spring.of(
27
+ () => {
28
+ if (panX !== undefined) {
29
+ return panX;
30
+ }
31
+ if (isOpen) {
32
+ return 0 as number;
33
+ }
34
+ return -100 as number;
35
+ },
36
+ { stiffness: 0.2, damping: 0.8 }
37
+ );
38
+
39
  $effect(() => {
40
  title ??= "New Chat";
41
  });
42
 
 
43
  beforeNavigate(() => {
44
+ isOpen = false;
45
  });
46
 
47
  let shouldFocusClose = $derived(isOpen && closeEl);
48
  let shouldRefocusOpen = $derived(!isOpen && browser && document.activeElement === closeEl);
49
+
50
  $effect(() => {
51
  if (shouldFocusClose) {
52
  closeEl?.focus();
 
62
  <button
63
  type="button"
64
  class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
65
+ onclick={() => (isOpen = true)}
66
  aria-label="Open menu"
67
  bind:this={openEl}><CarbonTextAlignJustify /></button
68
  >
 
76
  >
77
  </nav>
78
 
79
+ <nav
80
+ use:pan={() => ({ delay: 100, preventdefault: true, touchAction: "pan-left" })}
81
+ onpanup={(e: GestureCustomEvent) => {
82
+ if (!panStart || !panStartTime || !panX) {
83
+ return;
84
+ }
85
+ // measure the pan velocity to determine if the menu should snap open or closed
86
+ const drawerWidth = window.innerWidth;
87
+
88
+ const trueX = e.detail.x + (panX / 100) * drawerWidth;
89
+
90
+ const panDuration = Date.now() - panStartTime;
91
+ const panVelocity = (trueX - panStart) / panDuration;
92
+
93
+ panX = undefined;
94
+ panStart = undefined;
95
+ panStartTime = undefined;
96
+
97
+ console.log(panVelocity, trueX);
98
+ if (panVelocity < -1 || trueX < 50) {
99
+ isOpen = !isOpen;
100
+ }
101
+ }}
102
+ onpan={(e: PanCustomEvent) => {
103
+ if (e.detail.pointerType !== "touch") {
104
+ panX = undefined;
105
+ panStart = undefined;
106
+ panStartTime = undefined;
107
+ return;
108
+ }
109
+
110
+ panX ??= 0;
111
+ panStart ??= e.detail.x;
112
+ panStartTime ??= Date.now();
113
+
114
+ const drawerWidth = window.innerWidth;
115
+
116
+ const trueX = e.detail.x + (panX / 100) * drawerWidth;
117
+ const percentage = ((trueX - panStart) / drawerWidth) * 100;
118
+
119
+ panX = Math.max(-100, Math.min(0, percentage));
120
+ tween.set(panX, { instant: true });
121
+ }}
122
+ style="transform: translateX({Math.max(-100, Math.min(0, tween.current))}%);"
123
+ class="fixed inset-0 z-30 grid max-h-screen
124
+ grid-cols-1 grid-rows-[auto,1fr,auto,auto] bg-white pt-4 dark:bg-gray-900 md:hidden"
125
+ >
126
+ {#if page.url.pathname === base + "/"}
127
+ <button
128
+ type="button"
129
+ class="absolute right-0 top-0 z-10 flex size-12 items-center justify-center text-lg"
130
+ onclick={() => (isOpen = false)}
131
+ aria-label="Close menu"
132
+ bind:this={closeEl}><CarbonClose /></button
133
+ >
134
+ {/if}
135
+ {@render children?.()}
136
+ </nav>
src/lib/components/NavMenu.svelte CHANGED
@@ -85,7 +85,9 @@
85
  });
86
  </script>
87
 
88
- <div class="sticky top-0 flex flex-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0">
 
 
89
  <a
90
  class="flex items-center rounded-xl text-lg font-semibold"
91
  href="{envPublic.PUBLIC_ORIGIN}{base}/"
@@ -104,7 +106,7 @@
104
  {/if}
105
  </div>
106
  <div
107
- class="scrollbar-custom flex flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
108
  >
109
  {#await groupedConversations}
110
  {#if $page.data.nConversations > 0}
@@ -136,7 +138,7 @@
136
  {/await}
137
  </div>
138
  <div
139
- class="mt-0.5 flex flex-col gap-1 rounded-r-xl p-3 text-sm md:bg-gradient-to-l md:from-gray-50 md:dark:from-gray-800/30"
140
  >
141
  {#if user?.username || user?.email}
142
  <form
 
85
  });
86
  </script>
87
 
88
+ <div
89
+ class="sticky top-0 flex flex-none touch-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0"
90
+ >
91
  <a
92
  class="flex items-center rounded-xl text-lg font-semibold"
93
  href="{envPublic.PUBLIC_ORIGIN}{base}/"
 
106
  {/if}
107
  </div>
108
  <div
109
+ class="scrollbar-custom flex touch-pan-y flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
110
  >
111
  {#await groupedConversations}
112
  {#if $page.data.nConversations > 0}
 
138
  {/await}
139
  </div>
140
  <div
141
+ class="mt-0.5 flex touch-none flex-col gap-1 rounded-r-xl p-3 text-sm md:bg-gradient-to-l md:from-gray-50 md:dark:from-gray-800/30"
142
  >
143
  {#if user?.username || user?.email}
144
  <form
src/routes/+layout.svelte CHANGED
@@ -33,7 +33,6 @@
33
  data.conversations && untrack(() => (conversations = data.conversations));
34
  });
35
 
36
- let isNavOpen = $state(false);
37
  let isNavCollapsed = $state(false);
38
 
39
  let overloadedModalOpen = $state(false);
@@ -259,7 +258,7 @@
259
  : 'left-0'} *:transition-transform"
260
  />
261
 
262
- <MobileNav isOpen={isNavOpen} on:toggle={(ev) => (isNavOpen = ev.detail)} title={mobileNavTitle}>
263
  <NavMenu
264
  {conversations}
265
  user={data.user}
 
33
  data.conversations && untrack(() => (conversations = data.conversations));
34
  });
35
 
 
36
  let isNavCollapsed = $state(false);
37
 
38
  let overloadedModalOpen = $state(false);
 
258
  : 'left-0'} *:transition-transform"
259
  />
260
 
261
+ <MobileNav title={mobileNavTitle}>
262
  <NavMenu
263
  {conversations}
264
  user={data.user}