diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts index e8f8bfc..a8f670a 100644 --- a/frontend/e2e/header-auth.spec.ts +++ b/frontend/e2e/header-auth.spec.ts @@ -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('/') diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index 77e1ad4..9cc2a42 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -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 () => { diff --git a/frontend/src/__tests__/ChangeEmailPage.spec.ts b/frontend/src/__tests__/ChangeEmailPage.spec.ts index fee86cc..2e781f4 100644 --- a/frontend/src/__tests__/ChangeEmailPage.spec.ts +++ b/frontend/src/__tests__/ChangeEmailPage.spec.ts @@ -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(), diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts index 30e922b..991c59f 100644 --- a/frontend/src/__tests__/ContactPage.spec.ts +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -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', + ) }) }) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index fdc3f05..37115eb 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -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[0], + { hash: '' } as Parameters[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[0], + { hash: '' } as Parameters[1], + saved, + ) + expect(position).toBe(saved) + }) + it('resolves / to HomePage', async () => { await router.push('/') await router.isReady() diff --git a/frontend/src/assets/styles/base.css b/frontend/src/assets/styles/base.css index 05f5972..853da81 100644 --- a/frontend/src/assets/styles/base.css +++ b/frontend/src/assets/styles/base.css @@ -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; + } +} diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue index b83473f..5e0b410 100644 --- a/frontend/src/components/AppFooter.vue +++ b/frontend/src/components/AppFooter.vue @@ -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); + } +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 0a15ea3..df82fb9 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,5 @@