fix: changed frontend workflow and fixed login reference matching for real
This commit is contained in:
1 parent
f1e22f7073
commit
a5873f8886
12 files changed
+503
-46
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
titleEl.value?.focus()
|
||||
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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user