fix: changed frontend workflow and fixed login reference matching for real

This commit is contained in:
fsylvain committed 2026-09-25 22:31:44 +02:00
1 parent f1e22f7073
commit a5873f8886
12 files changed
+502 -45

No files matched your search

@@ -16,7 +16,7 @@ Feature: AuthShell and log-in screen conform to the design spec
card, per docs/design/components.md's AuthShell section and
docs/design/source/AuthCard.dc.html
And AppLogo is a reusable component (props include size) rendering the brand mark from
docs/design/source/brand/favicon.svg as inline SVG, not an <img>, so it can theme-switch
docs/design/source/brand/favicon.svg as inline SVG, not an <img>
And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route
changes can move focus to it, per AuthShell's a11y spec), and its description renders
through UPageCard's description prop — neither is freeform markup in the default slot
@@ -42,9 +42,9 @@ Feature: AuthShell and log-in screen conform to the design spec
an <img> tag
And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG)
And the logo is not wrapped in a link, since there is nothing public to go back to
And in light mode the black square has no edge stroke
And in dark mode the black square has a 1px edge in --color-chalk-600 (#444444), so it
doesn't disappear into the dark background
And the black square has no edge stroke in light mode or in dark mode, per
docs/design/source/AuthCard.dc.html's renderVals(), which computes the logo's edge as the
literal, non-theme-branched value "transparent"
Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling
Given AuthShell renders with title "Log in" and description
+10 -9
View File
@@ -6,13 +6,14 @@ No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView d
already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup,
reused here only as scaffolding, not as the thing under test).
1. **AppLogo renders inline SVG and theme-switches** — mount AuthShell with title "Log in";
assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element, left-aligned
(not centered) above the title/description block, sharing the card's 24px gap, at 32px, with
`role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer square has no
stroke in light mode; switch to dark mode (toggle the `.dark` class the way
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert a 1px stroke using
`--color-chalk-600` appears.
1. **AppLogo renders inline SVG with no edge in either mode** — mount AuthShell with title
"Log in"; assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element,
left-aligned (not centered) above the title/description block, sharing the card's 24px gap, at
32px, with `role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer
square has no stroke in light mode; switch to dark mode (toggle the `.dark` class the way
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert the outer square still has no
stroke, per `docs/design/source/AuthCard.dc.html`'s literal, non-theme-branched
`edge: 'transparent'`.
2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and
description "Welcome back. Log in to manage your articles."; assert
`wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the
@@ -58,8 +59,8 @@ Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", p
- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not
centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg
drawing exactly (white R on a black square), desktop and mobile; in dark mode the 1px
chalk-600 edge is visible around the square, in light mode it is absent.
drawing exactly (white R on a black square), desktop and mobile; no edge stroke is visible
around the square in either light or dark mode.
- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing
and receives focus (visible focus ring) on route change; description text matches
"Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark.
+102
View File
@@ -0,0 +1,102 @@
Feature: Log-in card sizing, alert placement and focus behavior match the Auth board
As the maintainer of RenewCMS
I want AuthShell's card padding, control heights, "Forgot password?" styling, field-error
presentation, theme select and h1 focus timing built exactly to the AuthCard board
(docs/design/source/AuthCard.dc.html) instead of Nuxt UI's untouched defaults
So that /login (and every other AuthShell-based public screen) matches the design board pixel
for pixel, at desktop and mobile, in light and dark
This complements features/auth_shell_login_conformance.feature, which covers AppLogo, the
UPageCard title/description wiring, the muted-background breakpoint and the neutral/error input
edge. This feature covers what that one doesn't: exact card/control measurements, the "Forgot
password?" link's own styling, the field-error icon and halo behavior, the theme select's
chevron, where the alert sits relative to the card, and when the h1 receives focus.
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And the card's padding, control heights and title size come from
docs/design/source/AuthCard.dc.html's renderVals(): pad "40px" and control height "40px"
from the sm breakpoint up; pad "8px 20px 0" (with a separate footer padding of
"0 20px 24px") and control height "44px" below sm; the title is always "font:600 20px/28px"
And the Password UFormField's "Forgot password?" link is styled color var(--ui-text-toned)
(muted), text-decoration underline, underline-offset 3px, per AuthCard.dc.html's f.link span
And a field error renders with the i-lucide-circle-alert icon in var(--ui-error), and the
field's edge/shadow is computed error-first, so an errored field that is also focused shows
only the single error-colored inset ring, never a focus ring stacked under or around it
And AuthShell's notice slot (the alert) renders inside the card's own layout, after the
title/description block and before the form — not as a sibling element above the card
And the footer's UColorModeSelect renders with no chevron/dropdown-arrow icon, as the last
(right-hand) child of the footer's space-between row, per AuthCard.dc.html's theme span
And on first paint, AuthShell's h1 (tabindex="-1") does not receive focus; focus moves to it
only when the title changes while AuthShell stays mounted (an in-app navigation between
public screens, e.g. /login to /register/reset/request), per components.md's AuthShell a11y
line ("On route change, focus moves to the h1")
And the h1 never shows a visible focus outline, since it is only ever focused programmatically
and is not part of the visible keyboard-tab order
Scenario: Card padding and control heights at sm and up
Given the user is on /login at a viewport of sm or wider
When AuthShell and the login form render
Then the card's own padding is 40px on every side
And the Username input, the Password input and the submit button are each 40px tall
And input text renders at 14px
Scenario: Card padding and control heights below sm
Given the user is on /login at a viewport below sm
When AuthShell and the login form render
Then the card's own padding is 8px top, 20px left and right, 0 bottom
And the footer (outside the card) has its own padding of 0 top, 20px left and right, 24px bottom
And the Username input, the Password input and the submit button are each 44px tall
And input text renders at 16px
Scenario: "Forgot password?" is underlined and muted
Given the user is on /login
When the Password field renders
Then the "Forgot password?" link uses the muted text color (--ui-text-toned)
And the link is underlined with a 3px underline offset
And the link carries no other emphasis (not bold, not the primary color)
Scenario: A field error shows the circle-alert icon with no extra focus halo
Given the user is on /login
When the user submits with Username "ma" and Password empty (the Validation errors state)
Then the Username field's error message is preceded by the i-lucide-circle-alert icon in the
error color
And the Password field's error message is preceded by the same icon in the same color
When the user tabs into the still-errored Username field so it is both errored and focused
Then the field shows exactly one inset ring, in the error color
And no second, differently colored focus ring is layered under or around it
Scenario: The theme select is right-aligned in the footer with no chevron
Given the user is on /login
When AuthShell's footer renders
Then the UColorModeSelect is the last (right-most) element in the footer's row
And it renders only its mode icon and label ("System", "Light" or "Dark")
And it renders no trailing chevron or dropdown-arrow icon
Scenario Outline: The alert renders inside the card, under the description
Given the user is redirected to <path>
When /login renders with its <state> alert
Then the alert is a child of the same card element that contains the title and description,
not a sibling positioned above the card
And the alert sits after the title/description block and before the Username field
And the alert's background uses UAlert's soft variant, which is dark-tinted automatically in
dark mode
Examples:
| path | state |
| /login?redirect=/article/12/edit&reason=expired | Session expired |
| /login?reason=password-changed | Password changed |
Scenario: The h1 is not focused on first load
Given the user navigates directly to /login (a fresh page load)
When AuthShell mounts and the h1 renders
Then the h1 does not receive focus
And focus remains on the document body (or wherever the browser places it on load)
Scenario: Focus moves to the h1 only on an in-app navigation between public screens
Given the user is on /login with AuthShell already mounted
When the user follows the "Forgot password?" link to /register/reset/request without a full
page reload
Then AuthShell's h1 receives focus once ResetRequestPage's title renders
And the h1 shows no visible outline when it receives this focus
+59
View File
@@ -0,0 +1,59 @@
# QA: Log-in card sizing, alert placement and focus behavior match the Auth board
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount AuthShell/LoginView directly; mock `useApi` (`GET /v1/auth/registration`,
`POST /v1/auth/login`) only as scaffolding, per features/login_screen.qa.md's setup.
1. **Card padding and control heights at sm+** — mount at a `>= 640px` viewport; assert the
card's own padding is 40px on every side (not Nuxt UI's untouched default); assert Username,
Password and the submit button each render at a computed height of 40px; assert input font-size
is 14px.
2. **Card padding and control heights below sm** — mount at a `< 640px` viewport; assert the
card's padding is 8px top / 20px sides / 0 bottom; assert the footer's own padding is 0 top /
20px sides / 24px bottom; assert Username, Password and the submit button are each 44px tall;
assert input font-size is 16px.
3. **"Forgot password?" styling** — assert the link's computed color equals `--ui-text-toned`,
`text-decoration-line: underline`, `text-underline-offset: 3px`, and that it carries no bold
weight or primary-color class.
4. **Field error icon and no stacked halo** — trigger the Validation errors state; assert each
field's error text is preceded by an `i-lucide-circle-alert` icon in the error color; then
focus the still-errored Username input and assert only one ring class (the error ring) is
present — the neutral/primary focus ring class is absent, not merely visually covered.
5. **Theme select has no chevron, is footer-last** — assert `UColorModeSelect` is the last child
of the footer's flex row; assert no chevron-down icon element renders inside it.
6. **Alert renders inside the card** — mount at `/login?reason=expired` and separately at
`/login?reason=password-changed`; assert the alert element's closest UPageCard/card ancestor is
the same element that contains the h1 (i.e., the alert is NOT a sibling before the card);
assert its DOM position is after the title/description block and before the Username field.
7. **h1 not focused on first load** — mount LoginView fresh (simulating first paint); assert
`document.activeElement` is not the h1.
8. **h1 focus on in-app navigation only** — mount AuthShell at /login, then update the `title`
prop the way an in-app route change to /register/reset/request would (per the existing `watch`
on `props.title`); assert the h1 now has focus.
## Visual (manual verification — `task visual:check -- log-in`, desktop and mobile, light and dark)
Reference: `docs/design/source/AuthCard.dc.html` via `RenewCMS Screens Auth.dc.html`, group
"Log in", all 9 states.
- [ ] Card padding is 40px on every side at desktop, and 8px/20px/0 (card) + 0/20px/24px
(footer) below sm, across all 9 states.
- [ ] Username, Password and the submit button are 40px tall at desktop and 44px below sm.
- [ ] "Forgot password?" is muted and underlined in every state that shows it.
- [ ] In the Validation errors state, both fields show the circle-alert icon and exactly one
ring around the field (no double ring), light and dark.
- [ ] The theme select shows icon + label only, no chevron, right-aligned in the footer.
- [ ] In Session expired and Password changed, the alert sits inside the card, under the
description, above the Username field, and is visibly dark-tinted (not a light-mode
background) in dark mode.
- [ ] Keyboard/focus: on a fresh /login load, nothing is auto-focused to the h1 (initial focus
stays with the browser/body); following "Forgot password?" to the reset-request screen moves
focus to that screen's h1, with no visible outline ring at any point.
## Manual verification
- **AppLogo edge in dark mode** — resolved by the amended
features/auth_shell_login_conformance.feature: AppLogo shows no edge stroke in either light or
dark mode, per AuthCard.dc.html's literal `edge: 'transparent'`. No check needed here; see that
feature's own QA file for the AppLogo assertions.
+1 -6
View File
@@ -17,12 +17,7 @@ withDefaults(
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)"
/>
<rect width="16" height="16" fill="#111111" />
<path
fill="#ffffff"
fill-rule="evenodd"
+24 -13
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
title: string
@@ -22,7 +23,9 @@ onMounted(() => {
updateBelowSm()
mediaQuery.addEventListener('change', updateBelowSm)
}
if (isInAppNavigation()) {
titleEl.value?.focus()
}
})
onUnmounted(() => {
@@ -30,20 +33,30 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center pt-[12vh]',
belowSm.value ? 'bg-default px-5' : 'bg-muted px-0',
'flex min-h-screen flex-col items-center',
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
])
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' : '',
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
header: 'mb-6',
title: 'text-xl font-semibold leading-7',
}))
const footerClass = computed(() => [
'mt-4 flex w-full items-center justify-between',
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = { trailing: 'hidden' }
watch(
() => props.title,
(title) => {
@@ -62,12 +75,6 @@ watch(
<template>
<main :class="mainClass">
<UAlert v-if="slots.notice" :class="['mb-4', cardClass]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard
variant="outline"
:description="description"
@@ -80,15 +87,19 @@ watch(
</template>
<template #title>
<h1 ref="titleEl" tabindex="-1">{{ title }}</h1>
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
<slot :control-size="controlSize" />
<div v-if="slots.notice" class="mb-4">
<slot name="notice" />
</div>
<slot :control-size="controlSize" :control-class="controlClass" />
</UPageCard>
<footer :class="['mt-4 flex w-full items-center justify-between', cardClass]">
<footer :class="footerClass">
<slot name="footer" />
<UColorModeSelect size="sm" />
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+2 -3
View File
@@ -30,13 +30,12 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
expect(svg.attributes('height')).toBe('48')
})
it('has no stroke by default, and a dark-mode 1px chalk-600 edge on the black square', () => {
it('has no edge stroke, in light mode or in dark mode', () => {
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()
expect(square.classes().some((c) => c.includes('stroke'))).toBe(false)
})
})
+15 -5
View File
@@ -1,7 +1,12 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import AuthShell from '../AuthShell.vue'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
return mount(AuthShell, {
@@ -25,7 +30,7 @@ describe('AuthShell renders its title as the page heading and sets the document
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot in an alert above the title and the footer slot outside the card', () => {
it('renders the notice slot as-is inside the card, after the title, and the footer slot outside the card', () => {
const wrapper = mountShell(
{ title: 'Log in' },
{
@@ -34,14 +39,19 @@ describe('AuthShell renders its notice and footer slots', () => {
},
)
const alertDescription = wrapper.find('[data-slot="description"]')
expect(alertDescription.text()).toBe('Your session expired.')
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1'))
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Forgot password?')
expect(card.element.contains(footerLink.element)).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { describe, it, expect, afterEach } from 'vitest'
import { describe, it, expect, afterEach, beforeEach } 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'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
function stubMatchMedia(belowSm: boolean) {
window.matchMedia = ((query: string) => ({
@@ -43,6 +44,10 @@ async function mountLogin() {
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
@@ -133,7 +138,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
})
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 () => {
it('uses the default background, an unbordered full-width card with the board padding split, and xl controls', async () => {
stubMatchMedia(true)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
@@ -147,6 +152,7 @@ describe('Below sm, AuthShell drops the muted background, the card border and th
const main = wrapper.find('main')
expect(main.classes()).toContain('bg-default')
expect(main.classes()).not.toContain('bg-muted')
expect(main.classes()).not.toContain('pt-[12vh]')
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).not.toContain('max-w-[400px]')
@@ -156,7 +162,9 @@ describe('Below sm, AuthShell drops the muted background, the card border and th
expect(button.attributes('data-size')).toBe('xl')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-5')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
})
})
@@ -0,0 +1,243 @@
import { describe, it, expect, afterEach, beforeEach } 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'
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
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(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
beforeEach(() => {
resetAuthShellNavigation()
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
resetAuthShellNavigation()
})
describe('Card padding and control heights at sm and up', () => {
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-10')
expect(container.classes()).not.toContain('pt-2')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-10')
expect(el.classes()).toContain('!text-sm')
}
const title = wrapper.find('[data-slot="title"]')
expect(title.classes()).toContain('text-xl')
expect(title.classes()).toContain('font-semibold')
expect(title.classes()).toContain('leading-7')
})
})
describe('Card padding and control heights below sm', () => {
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
stubMatchMedia(true)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
const footer = wrapper.find('footer')
expect(footer.classes()).toContain('pt-0')
expect(footer.classes()).toContain('px-5')
expect(footer.classes()).toContain('pb-6')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-11')
expect(el.classes()).toContain('!text-base')
}
})
})
describe('"Forgot password?" is underlined and muted', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
const wrapper = await mountLogin()
const link = wrapper.find('a[href="/register/reset/request"]')
expect(link.classes()).toContain('text-toned')
expect(link.classes()).toContain('underline')
expect(link.classes()).toContain('underline-offset-[3px]')
expect(link.classes()).not.toContain('text-primary')
expect(link.classes()).not.toContain('font-bold')
expect(link.classes()).not.toContain('font-semibold')
})
})
describe('A field error shows the circle-alert icon with no extra focus halo', () => {
it('precedes both field errors with the error-colored icon, and keeps a single error ring when focused', async () => {
const wrapper = await mountLogin()
await wrapper.find('input[placeholder="Your username"]').setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
for (const error of [usernameError, passwordError]) {
const icon = error.find('svg')
expect(icon.exists()).toBe(true)
expect(icon.html()).toContain('M12 8v4m0 4h.01')
expect(icon.classes()).toContain('text-error')
}
const usernameInput = wrapper.find('input[placeholder="Your username"]')
await usernameInput.trigger('focus')
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
})
})
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
const select = footer.find('button[aria-haspopup="listbox"]')
expect(select.exists()).toBe(true)
const children = Array.from(footer.element.children)
expect(children[children.length - 1]).toBe(select.element)
expect(select.text()).toContain('System')
const trailing = select.find('[data-slot="trailing"]')
expect(trailing.classes()).toContain('hidden')
})
})
describe('The alert renders inside the card, under the description', () => {
it.each([
{
path: '/login?redirect=/article/12/edit&reason=expired',
state: 'Session expired',
text: 'Your session expired',
},
{
path: '/login?reason=password-changed',
state: 'Password changed',
text: 'Password changed',
},
])(
'places the $state alert inside the card, after the title/description, before Username',
async ({ path, text }) => {
const wrapper = await mountLogin(path)
const card = wrapper.find('[data-testid="auth-card"]')
const alerts = wrapper.findAll('[role="alert"], [role="status"]')
expect(alerts.length).toBe(1)
const alert = alerts[0]!
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
},
)
})
describe('The h1 is not focused on first load', () => {
it('leaves focus on the document body after AuthShell mounts', () => {
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
const heading = wrapper.find('h1')
expect(heading.element).not.toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Focus moves to the h1 only on an in-app navigation between public screens', () => {
it('does not focus the first AuthShell mount, but focuses the second', () => {
const first = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(first.find('h1').element).not.toBe(document.activeElement)
first.unmount()
const second = mount(AuthShell, {
props: { title: 'Forgot password?', description: 'Reset it.' },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
expect(second.find('h1').element).toBe(document.activeElement)
expect(second.find('h1').classes()).toContain('outline-none')
second.unmount()
})
})
@@ -0,0 +1,11 @@
let hasEnteredBefore = false
export function isInAppNavigation(): boolean {
const wasEntered = hasEnteredBefore
hasEnteredBefore = true
return wasEntered
}
export function resetAuthShellNavigation(): void {
hasEnteredBefore = false
}
+20 -2
View File
@@ -211,7 +211,7 @@ onMounted(async () => {
/>
</template>
<template #default="{ controlSize }">
<template #default="{ controlSize, controlClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Username"
@@ -219,6 +219,12 @@ onMounted(async () => {
:error="usernameError || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<UInput
v-model="username"
placeholder="Your username"
@@ -228,6 +234,7 @@ onMounted(async () => {
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
/>
</UFormField>
@@ -239,10 +246,19 @@ onMounted(async () => {
:size="controlSize"
>
<template #hint>
<RouterLink to="/register/reset/request" class="text-primary text-sm">
<RouterLink
to="/register/reset/request"
class="text-toned text-sm underline underline-offset-[3px]"
>
Forgot password?
</RouterLink>
</template>
<template v-if="passwordError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
@@ -252,6 +268,7 @@ onMounted(async () => {
color="primary"
:highlight="!!passwordError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
>
<template #trailing>
@@ -273,6 +290,7 @@ onMounted(async () => {
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitDisabled"
>