feat(web): replace mobile nav row with hamburger menuUnverified
83ddff1 parent: 60367de modified
web/src/lib/components/Icon.svelte +1 -0 | @@ -50,6 +50,7 @@ | ||
| 50 | 50 | globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20", |
| 51 | 51 | sparkles: |
| 52 | 52 | "M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8zM19 14l.9 2.3 2.3.9-2.3.9L19 21l-.9-2.3-2.3-.9 2.3-.9z", |
| 53 | + menu: "M4 7h16M4 12h16M4 17h16", | |
| 53 | 54 | }; |
| 54 | 55 | </script> |
| 55 | 56 | |
| @@ -50,6 +50,7 @@ | |||
| 50 | globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20", | 50 | globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20", |
| 51 | sparkles: | 51 | sparkles: |
| 52 | "M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8zM19 14l.9 2.3 2.3.9-2.3.9L19 21l-.9-2.3-2.3-.9 2.3-.9z", | 52 | "M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8zM19 14l.9 2.3 2.3.9-2.3.9L19 21l-.9-2.3-2.3-.9 2.3-.9z", |
| 53 | + menu: "M4 7h16M4 12h16M4 17h16", | ||
| 53 | }; | 54 | }; |
| 54 | </script> | 55 | </script> |
| 55 | 56 | ||
modified
web/src/routes/+layout.svelte +51 -22 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <script lang="ts"> |
| 2 | 2 | import "../app.css"; |
| 3 | 3 | import { page } from "$app/state"; |
| 4 | - import { goto } from "$app/navigation"; | |
| 4 | + import { goto, afterNavigate } from "$app/navigation"; | |
| 5 | 5 | import type { LayoutData } from "./$types"; |
| 6 | 6 | import Logo from "$lib/components/Logo.svelte"; |
| 7 | 7 | import Icon from "$lib/components/Icon.svelte"; |
| @@ -20,6 +20,11 @@ | ||
| 20 | 20 | setCsrfToken(data.csrfToken); |
| 21 | 21 | }); |
| 22 | 22 | |
| 23 | + afterNavigate(() => { | |
| 24 | + menuOpen = false; | |
| 25 | + navOpen = false; | |
| 26 | + }); | |
| 27 | + | |
| 23 | 28 | const chromeless = $derived( |
| 24 | 29 | page.url.pathname === "/" || |
| 25 | 30 | page.url.pathname.startsWith("/auth/") || |
| @@ -27,6 +32,7 @@ | ||
| 27 | 32 | ); |
| 28 | 33 | |
| 29 | 34 | let menuOpen = $state(false); |
| 35 | + let navOpen = $state(false); | |
| 30 | 36 | let showShortcuts = $state(false); |
| 31 | 37 | let showInstall = $state(false); |
| 32 | 38 | |
| @@ -94,6 +100,7 @@ | ||
| 94 | 100 | // ignore |
| 95 | 101 | } |
| 96 | 102 | menuOpen = false; |
| 103 | + navOpen = false; | |
| 97 | 104 | goto("/", { invalidateAll: true }); |
| 98 | 105 | } |
| 99 | 106 | |
| @@ -174,6 +181,49 @@ | ||
| 174 | 181 | > |
| 175 | 182 | <Icon name="keyboard" class="h-4 w-4" /> |
| 176 | 183 | </button> |
| 184 | + | |
| 185 | + <!-- Mobile hamburger nav --> | |
| 186 | + <div class="relative md:hidden"> | |
| 187 | + <button | |
| 188 | + class="btn btn-ghost px-2" | |
| 189 | + onclick={() => (navOpen = !navOpen)} | |
| 190 | + title="Menu" | |
| 191 | + aria-label="Menu" | |
| 192 | + aria-expanded={navOpen} | |
| 193 | + > | |
| 194 | + <Icon name="menu" class="h-4 w-4" /> | |
| 195 | + </button> | |
| 196 | + {#if navOpen} | |
| 197 | + <button | |
| 198 | + class="fixed inset-0 z-40 cursor-default" | |
| 199 | + onclick={() => (navOpen = false)} | |
| 200 | + aria-label="Close menu" | |
| 201 | + tabindex="-1" | |
| 202 | + ></button> | |
| 203 | + <nav | |
| 204 | + class="absolute right-0 top-full z-50 mt-1 w-48 border-2 border-[var(--border)] bg-[var(--bg)] shadow-[4px_4px_0_0_var(--border)]" | |
| 205 | + > | |
| 206 | + {#each navItems as item} | |
| 207 | + <a | |
| 208 | + href={item.href} | |
| 209 | + class="flex items-center gap-2 border-b-2 border-[var(--border)] px-4 py-2.5 text-xs font-bold uppercase {isActive( | |
| 210 | + item.match, | |
| 211 | + ) | |
| 212 | + ? 'text-[var(--accent)]' | |
| 213 | + : 'text-[var(--muted)] hover:text-[var(--fg)] hover:bg-[var(--surface)]'}" | |
| 214 | + onclick={() => (navOpen = false)} | |
| 215 | + > | |
| 216 | + <Icon | |
| 217 | + name={item.icon} | |
| 218 | + class="h-3.5 w-3.5" | |
| 219 | + /> | |
| 220 | + {item.label} | |
| 221 | + </a> | |
| 222 | + {/each} | |
| 223 | + </nav> | |
| 224 | + {/if} | |
| 225 | + </div> | |
| 226 | + | |
| 177 | 227 | {#if data.user} |
| 178 | 228 | <div class="relative"> |
| 179 | 229 | <button |
| @@ -237,27 +287,6 @@ | ||
| 237 | 287 | </div> |
| 238 | 288 | {/if} |
| 239 | 289 | </div> |
| 240 | - | |
| 241 | - <!-- Mobile nav row --> | |
| 242 | - {#if !chromeless} | |
| 243 | - <nav | |
| 244 | - class="flex items-center gap-1 overflow-x-auto border-t-2 border-[var(--border)] px-2 py-1 md:hidden" | |
| 245 | - > | |
| 246 | - {#each navItems as item} | |
| 247 | - <a | |
| 248 | - href={item.href} | |
| 249 | - class="inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 text-[0.7rem] font-bold uppercase {isActive( | |
| 250 | - item.match, | |
| 251 | - ) | |
| 252 | - ? 'bg-[var(--fg)] text-[var(--bg)]' | |
| 253 | - : 'text-[var(--muted)]'}" | |
| 254 | - > | |
| 255 | - <Icon name={item.icon} class="h-3.5 w-3.5" /> | |
| 256 | - {item.label} | |
| 257 | - </a> | |
| 258 | - {/each} | |
| 259 | - </nav> | |
| 260 | - {/if} | |
| 261 | 290 | </header> |
| 262 | 291 | {/if} |
| 263 | 292 | |
| @@ -1,7 +1,7 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import "../app.css"; | 2 | import "../app.css"; |
| 3 | import { page } from "$app/state"; | 3 | import { page } from "$app/state"; |
| 4 | - import { goto } from "$app/navigation"; | 4 | + import { goto, afterNavigate } from "$app/navigation"; |
| 5 | import type { LayoutData } from "./$types"; | 5 | import type { LayoutData } from "./$types"; |
| 6 | import Logo from "$lib/components/Logo.svelte"; | 6 | import Logo from "$lib/components/Logo.svelte"; |
| 7 | import Icon from "$lib/components/Icon.svelte"; | 7 | import Icon from "$lib/components/Icon.svelte"; |
| @@ -20,6 +20,11 @@ | |||
| 20 | setCsrfToken(data.csrfToken); | 20 | setCsrfToken(data.csrfToken); |
| 21 | }); | 21 | }); |
| 22 | 22 | ||
| 23 | + afterNavigate(() => { | ||
| 24 | + menuOpen = false; | ||
| 25 | + navOpen = false; | ||
| 26 | + }); | ||
| 27 | + | ||
| 23 | const chromeless = $derived( | 28 | const chromeless = $derived( |
| 24 | page.url.pathname === "/" || | 29 | page.url.pathname === "/" || |
| 25 | page.url.pathname.startsWith("/auth/") || | 30 | page.url.pathname.startsWith("/auth/") || |
| @@ -27,6 +32,7 @@ | |||
| 27 | ); | 32 | ); |
| 28 | 33 | ||
| 29 | let menuOpen = $state(false); | 34 | let menuOpen = $state(false); |
| 35 | + let navOpen = $state(false); | ||
| 30 | let showShortcuts = $state(false); | 36 | let showShortcuts = $state(false); |
| 31 | let showInstall = $state(false); | 37 | let showInstall = $state(false); |
| 32 | 38 | ||
| @@ -94,6 +100,7 @@ | |||
| 94 | // ignore | 100 | // ignore |
| 95 | } | 101 | } |
| 96 | menuOpen = false; | 102 | menuOpen = false; |
| 103 | + navOpen = false; | ||
| 97 | goto("/", { invalidateAll: true }); | 104 | goto("/", { invalidateAll: true }); |
| 98 | } | 105 | } |
| 99 | 106 | ||
| @@ -174,6 +181,49 @@ | |||
| 174 | > | 181 | > |
| 175 | <Icon name="keyboard" class="h-4 w-4" /> | 182 | <Icon name="keyboard" class="h-4 w-4" /> |
| 176 | </button> | 183 | </button> |
| 184 | + | ||
| 185 | + <!-- Mobile hamburger nav --> | ||
| 186 | + <div class="relative md:hidden"> | ||
| 187 | + <button | ||
| 188 | + class="btn btn-ghost px-2" | ||
| 189 | + onclick={() => (navOpen = !navOpen)} | ||
| 190 | + title="Menu" | ||
| 191 | + aria-label="Menu" | ||
| 192 | + aria-expanded={navOpen} | ||
| 193 | + > | ||
| 194 | + <Icon name="menu" class="h-4 w-4" /> | ||
| 195 | + </button> | ||
| 196 | + {#if navOpen} | ||
| 197 | + <button | ||
| 198 | + class="fixed inset-0 z-40 cursor-default" | ||
| 199 | + onclick={() => (navOpen = false)} | ||
| 200 | + aria-label="Close menu" | ||
| 201 | + tabindex="-1" | ||
| 202 | + ></button> | ||
| 203 | + <nav | ||
| 204 | + class="absolute right-0 top-full z-50 mt-1 w-48 border-2 border-[var(--border)] bg-[var(--bg)] shadow-[4px_4px_0_0_var(--border)]" | ||
| 205 | + > | ||
| 206 | + {#each navItems as item} | ||
| 207 | + <a | ||
| 208 | + href={item.href} | ||
| 209 | + class="flex items-center gap-2 border-b-2 border-[var(--border)] px-4 py-2.5 text-xs font-bold uppercase {isActive( | ||
| 210 | + item.match, | ||
| 211 | + ) | ||
| 212 | + ? 'text-[var(--accent)]' | ||
| 213 | + : 'text-[var(--muted)] hover:text-[var(--fg)] hover:bg-[var(--surface)]'}" | ||
| 214 | + onclick={() => (navOpen = false)} | ||
| 215 | + > | ||
| 216 | + <Icon | ||
| 217 | + name={item.icon} | ||
| 218 | + class="h-3.5 w-3.5" | ||
| 219 | + /> | ||
| 220 | + {item.label} | ||
| 221 | + </a> | ||
| 222 | + {/each} | ||
| 223 | + </nav> | ||
| 224 | + {/if} | ||
| 225 | + </div> | ||
| 226 | + | ||
| 177 | {#if data.user} | 227 | {#if data.user} |
| 178 | <div class="relative"> | 228 | <div class="relative"> |
| 179 | <button | 229 | <button |
| @@ -237,27 +287,6 @@ | |||
| 237 | </div> | 287 | </div> |
| 238 | {/if} | 288 | {/if} |
| 239 | </div> | 289 | </div> |
| 240 | - | ||
| 241 | - <!-- Mobile nav row --> | ||
| 242 | - {#if !chromeless} | ||
| 243 | - <nav | ||
| 244 | - class="flex items-center gap-1 overflow-x-auto border-t-2 border-[var(--border)] px-2 py-1 md:hidden" | ||
| 245 | - > | ||
| 246 | - {#each navItems as item} | ||
| 247 | - <a | ||
| 248 | - href={item.href} | ||
| 249 | - class="inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 text-[0.7rem] font-bold uppercase {isActive( | ||
| 250 | - item.match, | ||
| 251 | - ) | ||
| 252 | - ? 'bg-[var(--fg)] text-[var(--bg)]' | ||
| 253 | - : 'text-[var(--muted)]'}" | ||
| 254 | - > | ||
| 255 | - <Icon name={item.icon} class="h-3.5 w-3.5" /> | ||
| 256 | - {item.label} | ||
| 257 | - </a> | ||
| 258 | - {/each} | ||
| 259 | - </nav> | ||
| 260 | - {/if} | ||
| 261 | </header> | 290 | </header> |
| 262 | {/if} | 291 | {/if} |
| 263 | 292 | ||