fix: login match design reference

This commit is contained in:
fsylvain committed 2026-09-25 21:28:12 +02:00
1 parent 001f9f4774
commit f1e22f7073
16 files changed
+1183 -57

No files matched your search

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppLogo from '../AppLogo.vue'
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it('renders an inline svg at 32px by default, not an img tag', () => {
const wrapper = mount(AppLogo)
expect(wrapper.find('img').exists()).toBe(false)
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(svg.attributes('height')).toBe('32')
})
it('carries alt text through role=img and aria-label, and is not wrapped in a link', () => {
const wrapper = mount(AppLogo)
const svg = wrapper.find('svg')
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(wrapper.find('a').exists()).toBe(false)
})
it('sizes the svg through the size prop', () => {
const wrapper = mount(AppLogo, { props: { size: 48 } })
const svg = wrapper.find('svg')
expect(svg.attributes('width')).toBe('48')
expect(svg.attributes('height')).toBe('48')
})
it('has no stroke by default, and a dark-mode 1px chalk-600 edge on the black square', () => {
const wrapper = mount(AppLogo)
const square = wrapper.find('rect')
expect(square.attributes('fill')).toBe('#111111')
expect(square.classes()).toContain('dark:stroke-(--color-chalk-600)')
expect(square.classes()).toContain('dark:stroke-1')
expect(square.attributes('stroke')).toBeUndefined()
})
})
@@ -17,7 +17,9 @@ describe('AuthShell renders its title as the page heading and sets the document
it('renders an h1 containing the title and sets document.title', () => {
const wrapper = mountShell({ title: 'Log in' })
expect(wrapper.find('h1').text()).toBe('Log in')
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(document.title).toBe('Log in · RenewCMS')
})
})
@@ -36,7 +38,7 @@ describe('AuthShell renders its notice and footer slots', () => {
expect(alertDescription.text()).toBe('Your session expired.')
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
@@ -0,0 +1,219 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import AuthShell from '@/components/AuthShell.vue'
import LoginView from '@/views/LoginView.vue'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
matches: belowSm,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
it("renders as UPageCard's first element, inside the card, left-aligned, at 32px, not as an img, alt text, not a link", () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.attributes('width')).toBe('32')
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
while (centeringAncestor && centeringAncestor !== card.element) {
expect(centeringAncestor.className).not.toMatch(
/items-center|justify-center|text-center/,
)
centeringAncestor = centeringAncestor.parentElement
}
const header = wrapper.find('[data-slot="header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('mb-6')
expect(header.classes()).not.toContain('mb-4')
expect(wrapper.find('img').exists()).toBe(false)
expect(svg.attributes('role')).toBe('img')
expect(svg.attributes('aria-label')).toBe('RenewCMS')
expect(svg.element.closest('a')).toBeNull()
})
})
describe("UPageCard's title is the page h1, and its description uses UPageCard's own styling", () => {
it('renders variant outline, the title through the title slot as h1, and the description through the prop', () => {
const wrapper = mount(AuthShell, {
props: {
title: 'Log in',
description: 'Welcome back. Log in to manage your articles.',
},
global: { plugins: [uiPlugin] },
})
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.exists()).toBe(true)
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.findAll('p').length).toBe(0)
})
})
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
stubMatchMedia(false)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
global: { plugins: [uiPlugin] },
})
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-muted')
expect(main.classes()).not.toContain('bg-default')
expect(main.classes()).toContain('pt-[12vh]')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).toContain('max-w-[400px]')
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).not.toContain('p-5')
})
})
describe('Below sm, AuthShell drops the muted background, the card border and the card padding', () => {
it('uses the default background, an unbordered full-width card with 20px padding, and xl controls', async () => {
stubMatchMedia(true)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
slots: {
default: `<template #default="{ controlSize }"><button :data-size="controlSize">Submit</button></template>`,
},
global: { plugins: [uiPlugin] },
})
await flushPromises()
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-default')
expect(main.classes()).not.toContain('bg-muted')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).not.toContain('max-w-[400px]')
expect(card.classes()).toContain('ring-0')
const button = wrapper.find('button')
expect(button.attributes('data-size')).toBe('xl')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-5')
})
})
describe('The login form is a UForm', () => {
it('wraps the Username field, the Password field and the submit button in a UForm, not a plain form', async () => {
const wrapper = await mountLogin()
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
describe('"Forgot password?" renders in the Password field\'s hint slot', () => {
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
})
})
describe('Inputs are neutral by default and turn to the error edge only when their field has an error', () => {
it('starts neutral, then turns to the error edge with the field messages on invalid submit', async () => {
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
expect(passwordInput.classes()).not.toContain('ring-error')
await usernameInput.setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
expect(passwordAfter.classes()).not.toContain('ring-accented')
expect(wrapper.text()).toContain('Use at least 3 characters.')
expect(wrapper.text()).toContain('Enter your password.')
})
})