Make customer-facing UI usable on smartphones.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m22s
CI / E2E browser tests (pull_request) Failing after 1m3s

Mobile traffic was breaking on narrow viewports because the header nav
overflowed and several pages used desktop-only spacing. This adds a
shared phone breakpoint, a hamburger menu, and scroll-to-top on route
changes so footer and menu navigation always land at the top of the page.

- Add --page-gutter and max-width 639px rules in base.css
- AppHeader: hamburger panel on small screens; flat account links on mobile
- AppFooter: stack footer links vertically on phones
- Home, compose, edit order, orders, auth, and legal pages: tighter gutters
  and responsive layout (orders card actions stack; home grids single-column)
- Router scrollBehavior: scroll to top on navigation; restore on browser back
- Tests: AppHeader menu toggle, Router scrollBehavior, mobile Playwright checks

Admin page is intentionally unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joakim Mörling 2026-05-26 13:03:35 +02:00
parent 71a3225a11
commit 7a95c1423c
25 changed files with 454 additions and 57 deletions

View file

@ -200,6 +200,40 @@ test.describe('Header auth state', () => {
})
})
test.describe('Header on mobile viewport', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('menu reveals navigation links when authenticated', async ({ page }) => {
await authenticateUser(page)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).not.toBeVisible()
await header.getByRole('button', { name: 'Öppna meny' }).click()
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).toBeVisible()
await expect(
header.getByRole('link', { name: 'Byt e-postadress' }),
).toBeVisible()
})
test('home page has no horizontal overflow', async ({ page }) => {
await page.goto('/')
const scrollWidth = await page.evaluate(
() => document.documentElement.scrollWidth,
)
const clientWidth = await page.evaluate(
() => document.documentElement.clientWidth,
)
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
})
})
async function authenticateUser(page: import('@playwright/test').Page) {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')

View file

@ -105,7 +105,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.find('.app-header__logout').exists()).toBe(false)
})
it('does not show user email', () => {
@ -178,7 +178,7 @@ describe('AppHeader', () => {
it('shows settings menu with account links', async () => {
const { wrapper } = mountAuthenticated()
expect(wrapper.text()).not.toContain('Byt lösenord')
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0)
await wrapper.find('.app-header__settings-trigger').trigger('click')
@ -190,15 +190,26 @@ describe('AppHeader', () => {
expect(links[1].text()).toBe('Byt lösenord')
})
it('toggles mobile menu open state when menu button is clicked', async () => {
const { wrapper } = mountAuthenticated()
await wrapper.find('.app-header__menu-toggle').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.classes()).toContain('app-header--menu-open')
expect(document.body.classList.contains('nav-menu-open')).toBe(true)
expect(wrapper.text()).toContain('Byt e-postadress')
})
it('highlights settings trigger on change password page', async () => {
const { wrapper, router } = mountAuthenticated()
await router.push('/andra-losenord')
await router.isReady()
await wrapper.vm.$nextTick()
expect(
wrapper.find('.app-header__settings-trigger').classes(),
).toContain('app-header__settings-trigger--active')
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
)
})
it('highlights settings trigger on change email page', async () => {
@ -207,9 +218,9 @@ describe('AppHeader', () => {
await router.isReady()
await wrapper.vm.$nextTick()
expect(
wrapper.find('.app-header__settings-trigger').classes(),
).toContain('app-header__settings-trigger--active')
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
)
})
it('does not highlight settings trigger on other pages', async () => {

View file

@ -15,7 +15,10 @@ describe('ChangeEmailPage', () => {
it('renders current email and form fields', () => {
const pinia = createPinia()
setActivePinia(pinia)
localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' }))
localStorage.setItem(
'auth_token',
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
)
const router = createRouter({
history: createMemoryHistory(),
@ -35,7 +38,10 @@ describe('ChangeEmailPage', () => {
it('shows auth email from store', () => {
const pinia = createPinia()
setActivePinia(pinia)
localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' }))
localStorage.setItem(
'auth_token',
makeJwt({ sub: 'user@example.com', role: 'user' }),
)
const router = createRouter({
history: createMemoryHistory(),

View file

@ -29,6 +29,8 @@ describe('ContactPage', () => {
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
expect(link.exists()).toBe(true)
expect(link.text()).toBe('support@bilhej.se')
expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se')
expect(link.attributes('aria-label')).toBe(
'Skicka till support: support@bilhej.se',
)
})
})

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import router from '@/router'
import router, { scrollBehavior } from '@/router'
describe('Router', () => {
beforeEach(() => {
@ -8,6 +8,25 @@ describe('Router', () => {
localStorage.clear()
})
it('scrolls to top on route change without hash', () => {
const position = scrollBehavior(
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
null,
)
expect(position).toEqual({ top: 0, left: 0 })
})
it('restores saved position when using browser back', () => {
const saved = { top: 120, left: 0 }
const position = scrollBehavior(
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
saved,
)
expect(position).toBe(saved)
})
it('resolves / to HomePage', async () => {
await router.push('/')
await router.isReady()

View file

@ -94,6 +94,10 @@ a {
/* transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
/* layout */
--page-gutter: var(--space-lg);
--header-height: 3.25rem;
}
/* ── Body ────────────────────────────────────────────────────────────── */
@ -407,3 +411,34 @@ a[href]:hover {
.text-xs {
font-size: 0.75rem;
}
/* ── Responsive (customer-facing; max 639px = phone) ─────────────────── */
@media (max-width: 639px) {
:root {
--page-gutter: var(--space-md);
}
h1 {
font-size: 1.5rem;
}
.container,
.container--narrow,
.container--wide {
padding-inline: var(--page-gutter);
}
.surface-card {
padding: var(--space-md);
}
.btn--block-sm {
width: 100%;
}
}
@media (min-width: 640px) {
.btn--block-sm {
width: auto;
}
}

View file

@ -32,7 +32,7 @@ import { RouterLink } from 'vue-router'
.app-footer__inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg);
padding: var(--space-xl) var(--page-gutter);
text-align: center;
}
@ -66,4 +66,15 @@ import { RouterLink } from 'vue-router'
font-size: 0.75rem;
margin: 0;
}
@media (max-width: 639px) {
.app-footer__links {
flex-direction: column;
gap: var(--space-md);
}
.app-footer__inner {
padding-block: var(--space-lg);
}
}
</style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
@ -7,10 +7,10 @@ const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isSettingsActive = computed(
() =>
route.name === 'change-email' || route.name === 'change-password',
() => route.name === 'change-email' || route.name === 'change-password',
)
const settingsOpen = ref(false)
const menuOpen = ref(false)
const settingsRef = ref<HTMLElement | null>(null)
function toggleSettings() {
@ -21,30 +21,67 @@ function closeSettings() {
settingsOpen.value = false
}
function toggleMenu() {
menuOpen.value = !menuOpen.value
if (!menuOpen.value) {
closeSettings()
}
}
function closeMenu() {
menuOpen.value = false
closeSettings()
}
function handleDocumentClick(event: MouseEvent) {
if (!settingsRef.value?.contains(event.target as Node)) {
settingsOpen.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu()
}
}
function handleNavClick() {
closeMenu()
}
function handleLogout() {
closeMenu()
auth.logout()
router.push('/')
}
watch(
() => route.fullPath,
() => {
closeMenu()
},
)
watch(menuOpen, (open) => {
document.body.classList.toggle('nav-menu-open', open)
})
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('keydown', handleKeydown)
document.body.classList.remove('nav-menu-open')
})
</script>
<template>
<header class="app-header">
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }">
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo">
<RouterLink to="/" class="app-header__logo" @click="handleNavClick">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
@ -71,13 +108,62 @@ onUnmounted(() => {
</svg>
Bilhej
</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<button
type="button"
class="app-header__menu-toggle"
:aria-expanded="menuOpen"
aria-controls="app-header-nav"
@click="toggleMenu"
>
<span class="visually-hidden">{{
menuOpen ? 'Stäng meny' : 'Öppna meny'
}}</span>
<svg
v-if="!menuOpen"
class="app-header__menu-icon"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M4 7h16M4 12h16M4 17h16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<svg
v-else
class="app-header__menu-icon"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<nav id="app-header-nav" class="app-header__nav">
<RouterLink to="/" class="app-header__link" @click="handleNavClick"
>Hem</RouterLink
>
<template v-if="!auth.isAuthenticated">
<RouterLink to="/logga-in" class="app-header__link"
<RouterLink
to="/logga-in"
class="app-header__link"
@click="handleNavClick"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link"
<RouterLink
to="/registrera"
class="app-header__link"
@click="handleNavClick"
>Registrera</RouterLink
>
</template>
@ -86,11 +172,37 @@ onUnmounted(() => {
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
@click="handleNavClick"
>Admin</RouterLink
>
<RouterLink to="/orders" class="app-header__link"
<RouterLink
to="/orders"
class="app-header__link"
@click="handleNavClick"
>Mina beställningar</RouterLink
>
<RouterLink
to="/andra-epost"
class="app-header__link app-header__link--settings-mobile"
:class="{
'app-header__link--active-settings':
route.name === 'change-email',
}"
@click="handleNavClick"
>
Byt e-postadress
</RouterLink>
<RouterLink
to="/andra-losenord"
class="app-header__link app-header__link--settings-mobile"
:class="{
'app-header__link--active-settings':
route.name === 'change-password',
}"
@click="handleNavClick"
>
Byt lösenord
</RouterLink>
<div ref="settingsRef" class="app-header__settings">
<button
type="button"
@ -165,9 +277,10 @@ onUnmounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--space-lg);
padding: 0.875rem var(--page-gutter);
}
.app-header__logo {
@ -178,6 +291,7 @@ onUnmounted(() => {
font-weight: 700;
color: var(--color-ink);
text-decoration: none;
flex-shrink: 0;
}
.app-header__logo-icon {
@ -185,6 +299,26 @@ onUnmounted(() => {
height: 1.5rem;
}
.app-header__menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
color: var(--color-ink);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
flex-shrink: 0;
}
.app-header__menu-icon {
width: 1.375rem;
height: 1.375rem;
}
.app-header__nav {
display: flex;
align-items: center;
@ -209,6 +343,11 @@ onUnmounted(() => {
background: var(--color-primary-soft);
}
.app-header__link--active-settings {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
@ -220,10 +359,18 @@ onUnmounted(() => {
color: var(--color-primary-dark);
}
.app-header__link--settings-mobile {
display: none;
}
.app-header__email {
color: var(--color-muted);
font-size: 0.8125rem;
padding: 0 0.5rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-header__settings {
@ -313,4 +460,69 @@ onUnmounted(() => {
border-color: var(--color-danger);
background: var(--color-danger-soft);
}
@media (max-width: 639px) {
.app-header__menu-toggle {
display: inline-flex;
}
.app-header__inner {
flex-wrap: wrap;
align-items: center;
}
.app-header__nav {
display: none;
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 0.25rem;
padding: var(--space-sm) 0 var(--space-md);
border-top: 1px solid var(--color-border);
}
.app-header--menu-open .app-header__nav {
display: flex;
}
.app-header__link,
.app-header__settings-trigger,
.app-header__logout {
width: 100%;
justify-content: flex-start;
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 2.75rem;
}
.app-header__link--settings-mobile {
display: flex;
align-items: center;
}
.app-header__settings {
display: none;
}
.app-header__email {
order: 10;
max-width: none;
padding: var(--space-sm) 1rem 0;
font-size: 0.875rem;
text-align: center;
white-space: normal;
word-break: break-all;
}
.app-header__logout {
margin-top: var(--space-xs);
}
}
</style>
<style>
body.nav-menu-open {
overflow: hidden;
}
</style>

View file

@ -82,7 +82,7 @@ const highlights = [
.about {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
}
.about__hero {

View file

@ -124,8 +124,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -148,8 +148,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -282,8 +282,10 @@ async function handleSubmit() {
text-decoration: underline;
}
@media (max-width: 768px) {
@media (max-width: 639px) {
.compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr;
}

View file

@ -38,7 +38,8 @@ async function handleSubmit() {
} else if (err instanceof ApiError) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
errorMessage.value =
'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
}
} finally {
submitting.value = false
@ -106,8 +107,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -86,7 +86,7 @@ const contactChannels = [
.contact {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
}
.contact__hero {
@ -197,7 +197,9 @@ const contactChannels = [
background: var(--color-primary-soft);
border: 1px solid #bfdbfe;
border-radius: var(--radius-md);
transition: background var(--transition-fast), border-color var(--transition-fast);
transition:
background var(--transition-fast),
border-color var(--transition-fast);
}
.contact__mailto:hover {

View file

@ -327,8 +327,10 @@ onMounted(loadOrder)
text-decoration: underline;
}
@media (max-width: 768px) {
@media (max-width: 639px) {
.compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr;
}

View file

@ -87,8 +87,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -1140,11 +1140,11 @@ async function handleLookup(lookedUpPlate: string) {
line-height: 1.65;
}
@media (max-width: 900px) {
@media (max-width: 639px) {
.home__hero {
grid-template-columns: 1fr;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-lg);
padding: var(--space-xl) var(--page-gutter);
margin-top: 0;
border-radius: 0;
border-left: none;
@ -1168,5 +1168,15 @@ async function handleLookup(lookedUpPlate: string) {
.home__use--wide .home__use-icon {
margin-bottom: var(--space-md);
}
.home__uses,
.home__steps,
.home__trust {
padding-inline: var(--page-gutter);
}
.home__trust-inner {
padding: var(--space-lg);
}
}
</style>

View file

@ -103,8 +103,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -316,8 +316,8 @@ onMounted(loadOrders)
<style scoped>
.page {
max-width: 48rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__title {
@ -604,4 +604,33 @@ onMounted(loadOrders)
.orders__loading {
padding: var(--space-2xl) 0;
}
@media (max-width: 639px) {
.orders__card-head {
flex-direction: column;
align-items: flex-start;
}
.orders__plate-badge {
max-width: 100%;
}
.orders__links {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.orders__link-sep {
display: none;
}
.orders__text-link {
padding: 0.5rem 0;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style>

View file

@ -125,8 +125,8 @@ async function confirmPayment() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -84,8 +84,8 @@ const sections = [
<p class="policy__eyebrow">Integritet</p>
<h1 class="policy__title">Integritetspolicy</h1>
<p class="policy__lead">
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar brev
via tjänsten, och vilka rättigheter du har.
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar
brev via tjänsten, och vilka rättigheter du har.
</p>
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
</section>
@ -113,7 +113,8 @@ const sections = [
>kontakt@bilhej.se</a
>
eller vår
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink>.
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink
>.
</p>
</div>
</section>
@ -124,7 +125,7 @@ const sections = [
.policy {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
}
.policy__hero {

View file

@ -165,8 +165,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -149,8 +149,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -132,7 +132,8 @@ const sections = [
>support@bilhej.se</a
>
eller vår
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink>.
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink
>.
</p>
</div>
</section>
@ -143,7 +144,7 @@ const sections = [
.terms {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
}
.terms__hero {

View file

@ -1,4 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
} from 'vue-router'
import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue'
@ -19,8 +23,23 @@ import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia'
export function scrollBehavior(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
savedPosition: { left: number; top: number } | null,
) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash, top: 0, behavior: 'smooth' as const }
}
return { top: 0, left: 0 }
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior,
routes: [
{
path: '/',