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

+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{ size: 32 },
)
</script>
<template>
<svg
role="img"
aria-label="RenewCMS"
:width="size"
:height="size"
viewBox="0 0 16 16"
shape-rendering="crispEdges"
xmlns="http://www.w3.org/2000/svg"
>
<rect
width="16"
height="16"
fill="#111111"
class="dark:stroke-1 dark:stroke-(--color-chalk-600)"
/>
<path
fill="#ffffff"
fill-rule="evenodd"
d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"
/>
</svg>
</template>
+61 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useSlots, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
const props = defineProps<{
title: string
@@ -7,6 +8,41 @@ const props = defineProps<{
}>()
const slots = useSlots()
const titleEl = ref<HTMLHeadingElement | null>(null)
const belowSm = ref(false)
let mediaQuery: MediaQueryList | null = null
function updateBelowSm() {
belowSm.value = mediaQuery?.matches ?? false
}
onMounted(() => {
if (typeof window.matchMedia === 'function') {
mediaQuery = window.matchMedia('(max-width: 39.9975rem)')
updateBelowSm()
mediaQuery.addEventListener('change', updateBelowSm)
}
titleEl.value?.focus()
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', updateBelowSm)
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center pt-[12vh]',
belowSm.value ? 'bg-default px-5' : 'bg-muted px-0',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'p-5' : '',
header: 'mb-6',
}))
watch(
() => props.title,
@@ -15,26 +51,42 @@ watch(
},
{ immediate: true },
)
watch(
() => props.title,
() => {
titleEl.value?.focus()
},
)
</script>
<template>
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
<main :class="mainClass">
<UAlert v-if="slots.notice" :class="['mb-4', cardClass]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
<UPageCard
variant="outline"
:description="description"
:ui="cardUi"
data-testid="auth-card"
:class="cardClass"
>
<template #header>
<AppLogo :size="32" />
</template>
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
<template #title>
<h1 ref="titleEl" tabindex="-1">{{ title }}</h1>
</template>
<slot />
<slot :control-size="controlSize" />
</UPageCard>
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
<footer :class="['mt-4 flex w-full items-center justify-between', cardClass]">
<slot name="footer" />
<UColorModeSelect size="sm" />
</footer>
@@ -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.')
})
})