fix: login match design reference
This commit is contained in:
1 parent
001f9f4774
commit
f1e22f7073
16 files changed
+1183
-57
No files matched your search
@@ -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>
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user