fix: language buttons real design / conformance

This commit is contained in:
fsylvain committed 2026-10-04 17:08:43 +02:00
1 parent 2c8f39273c
commit 0490a4c494
23 files changed
+1408 -412

No files matched your search

@@ -296,10 +296,18 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(labels).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]').forEach((item) =>
expect(item.className).toContain('h-12'),
)
})
@@ -325,7 +333,7 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('applies a theme choice and closes the sheet', async () => {
it('applies a theme choice and keeps the sheet open', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountShell('/home', { width: MOBILE_WIDTH })
@@ -339,13 +347,13 @@ describe('Account menu: opened from the drawer on mobile', () => {
await flushPromises()
const dark = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemcheckbox"]'),
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemradio"]'),
).find((item) => item.textContent?.trim() === 'Dark')!
dark.click()
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
})
it('logs out from the sheet', async () => {
+21 -5
View File
@@ -51,7 +51,7 @@ function menu() {
function itemsIn(container: ParentNode) {
return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
container.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
) as HTMLElement[]
}
@@ -98,18 +98,26 @@ describe('The chip opens the account menu instead of logging out', () => {
})
describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
it('lists the header, Profile, the theme and language choices and Log out with a divider between its groups', async () => {
await mountMenu()
await openMenu()
const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
expect(
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(4)
})
it('shows the display name in the header and the chip when there is one', async () => {
@@ -297,7 +305,15 @@ describe('The collapsed rail keeps the menu and an accessible name', () => {
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
})
@@ -0,0 +1,840 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import type { Component } from 'vue'
import App from '@/App.vue'
import AuthShell from '@/components/AuthShell.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import UserMenu from '@/components/UserMenu.vue'
import { useApi } from '@/composables/useApi'
import { useColorMode } from '@/composables/useColorMode'
import { useSessionStore } from '@/stores/session'
import { initializeLocale, locale, setLocale, t } from '@/i18n'
import { messages } from '@/i18n/messages'
import router from '@/router'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
return {
useApi: vi.fn(),
authRedirectPending: ref(false),
ApiError: class extends Error {
constructor(
public status: number,
public retryAfter?: number,
public body?: string,
) {
super('API failure')
}
},
}
})
const request = vi.fn()
const SIGNED_IN = {
id: 1,
verified: true,
username: 'marta',
displayName: '',
email: 'marta@renew.example',
role: 'owner',
}
let wrapper: VueWrapper | undefined
function signIn() {
const session = useSessionStore()
session.session = SIGNED_IN
session.status = 'ready'
}
function signOut() {
const session = useSessionStore()
session.session = null
session.status = 'ready'
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
useColorMode().preference = 'light'
request.mockReset().mockResolvedValue([])
vi.mocked(useApi).mockReturnValue({
request,
getMe: vi.fn().mockResolvedValue(null),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(async () => {
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function press(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }),
)
}
function pointerActivate(element: Element) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function clickOutside() {
const outside = document.createElement('button')
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
}
function menu() {
return document.body.querySelector<HTMLElement>('[role="menu"]')
}
function rows(container: ParentNode = document.body) {
return Array.from(
container.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemradio"]'),
)
}
function row(label: string) {
return rows().find((item) => item.textContent?.trim() === label)!
}
function checked(items: HTMLElement[]) {
return items.filter((item) => item.getAttribute('aria-checked') === 'true')
}
function sectionLabel(text: string) {
return Array.from(menu()!.querySelectorAll<HTMLElement>('div')).find(
(el) => el.textContent?.trim() === text && el.className.includes('uppercase'),
)!
}
async function nextFrame() {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
}
type Surface = 'desktop UserMenu popover' | 'mobile AccountMenuSheet'
const surfaces: Surface[] = ['desktop UserMenu popover', 'mobile AccountMenuSheet']
async function openAccountMenu(surface: Surface) {
signIn()
await router.push('/profile')
if (surface === 'desktop UserMenu popover') {
wrapper = mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, collapsed: false },
global: { plugins: [ui, router] },
})
document.body.querySelector<HTMLButtonElement>('button[aria-haspopup]')!.click()
} else {
wrapper = mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, open: true },
global: { plugins: [ui, router] },
})
}
await nextFrame()
}
function authChip() {
return document.body.querySelector<HTMLButtonElement>('footer button[aria-haspopup="menu"]')!
}
async function mountAuthShell(belowSm: boolean, slots: Record<string, string> = {}) {
useColorMode().preference = 'system'
setViewportWidth(belowSm ? MOBILE_WIDTH : DESKTOP_WIDTH)
wrapper = mount(AuthShell, {
attachTo: document.body,
props: { title: 'Log in' },
slots,
global: { plugins: [ui] },
})
await flushPromises()
}
describe('Account menu lists Profile, Theme, Language and Log out in design order', () => {
it.each(surfaces)('orders the entries in the %s', async (surface) => {
await openAccountMenu(surface)
const text = menu()!.textContent!
const order = [
'Profile',
'Theme',
'System',
'Light',
'Dark',
'Language',
'English',
'Français',
'Log out',
]
const positions = order.map((entry) =>
text.indexOf(entry, entry === 'Language' ? text.indexOf('Dark') : 0),
)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
expect(positions.every((position) => position >= 0)).toBe(true)
expect(rows().map((item) => item.textContent?.trim())).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it.each(surfaces)(
'styles the section labels and removes the old picker in the %s',
async (surface) => {
await openAccountMenu(surface)
for (const label of ['Theme', 'Language']) {
const classes = sectionLabel(label).className
expect(classes).toContain('text-[0.6875rem]')
expect(classes).toContain('font-medium')
expect(classes).toContain('uppercase')
expect(classes).toContain('tracking-[0.04em]')
expect(classes).toContain('text-muted')
expect(classes).toContain(
surface === 'desktop UserMenu popover' ? 'pt-1.5' : 'pt-2',
)
expect(classes).toContain(surface === 'desktop UserMenu popover' ? 'px-2' : 'px-4')
}
expect(document.body.querySelector('[role="combobox"], [role="listbox"]')).toBeNull()
expect(
menu()!.querySelector('[data-icon="languages"], .iconify--lucide--languages'),
).toBeNull()
},
)
it.each(surfaces)('gives every row the design height in the %s', async (surface) => {
await openAccountMenu(surface)
for (const item of rows()) {
expect(item.className).toContain(
surface === 'desktop UserMenu popover' ? 'h-8' : 'h-12',
)
}
})
})
describe('Language rows are a radio group naming each language in its own name', () => {
it.each(
surfaces.flatMap((surface) => [
[surface, 'en', 'English'],
[surface, 'fr', 'Français'],
]),
)('in the %s with locale %s checks %s', async (surface, code, name) => {
setLocale(code as 'en' | 'fr')
await openAccountMenu(surface as Surface)
const group = menu()!.querySelector<HTMLElement>(
`[role="group"][aria-labelledby="${sectionLabel(t('Language')).id}"]`,
)!
const languageRows = rows(group)
expect(languageRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
])
expect(
languageRows.map((item) => [item.textContent?.trim(), item.getAttribute('lang')]),
).toEqual([
['English', 'en'],
['Français', 'fr'],
])
expect(checked(languageRows).map((item) => item.textContent?.trim())).toEqual([name])
expect(
languageRows.filter((item) =>
item.querySelector('svg[data-slot="itemTrailingIcon"], svg.iconify'),
).length,
).toBe(1)
expect(sectionLabel(t('Language')).textContent).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(sectionLabel(t('Theme')).textContent).toBe(code === 'fr' ? 'Thème' : 'Theme')
})
})
describe('Theme rows are radio items that keep the menu open', () => {
it.each([
['desktop UserMenu popover', 'light'],
['mobile AccountMenuSheet', 'dark'],
] as const)('in the %s with preference %s', async (surface, preference) => {
useColorMode().preference = preference
await openAccountMenu(surface)
const themeRows = ['System', 'Light', 'Dark'].map(row)
expect(themeRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
'menuitemradio',
])
expect(checked(themeRows).map((item) => item.textContent?.trim())).toEqual([
preference === 'light' ? 'Light' : 'Dark',
])
pointerActivate(row('System'))
await flushPromises()
expect(useColorMode().preference).toBe('system')
expect(menu()).not.toBeNull()
expect(row('System').getAttribute('aria-checked')).toBe('true')
})
})
describe('Choosing a language in the account menu applies it at once and keeps the menu open', () => {
it.each(surfaces)('in the %s', async (surface) => {
await openAccountMenu(surface)
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
document.documentElement.lang,
localStorage.getItem('renewcms-language'),
menu() !== null,
row('Français').getAttribute('aria-checked'),
row('English').getAttribute('aria-checked'),
sectionLabel('Thème') !== undefined,
sectionLabel('Langue') !== undefined,
row('Profil') !== undefined,
request,
router.currentRoute.value.path,
useColorMode().preference,
]).toEqual([
'fr',
'fr',
'fr',
true,
'true',
'false',
true,
true,
true,
expect.objectContaining({ mock: expect.objectContaining({ calls: [] }) }),
'/profile',
'light',
])
pointerActivate(row('English'))
await flushPromises()
expect([locale.value, document.documentElement.lang, menu() !== null]).toEqual([
'en',
'en',
true,
])
})
it.each(['desktop', 'mobile'] as const)(
'keeps an unsaved display name on /profile in the %s layout',
async (layout) => {
signIn()
setViewportWidth(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH)
await router.push('/profile')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="off"]').setValue('Alice B.')
if (layout === 'mobile') {
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
await flushPromises()
}
document.body
.querySelector<HTMLButtonElement>(
layout === 'mobile'
? '[role="dialog"] button[aria-haspopup]'
: 'button[aria-haspopup]',
)!
.click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
(wrapper.find('input[autocomplete="off"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
request.mock.calls.length,
menu() !== null,
]).toEqual(['fr', 'Alice B.', '/profile', 0, true])
},
)
it('applies the language for the session when storage cannot be written', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await openAccountMenu('desktop UserMenu popover')
pointerActivate(row('Français'))
await flushPromises()
expect([locale.value, row('Français').getAttribute('aria-checked')]).toEqual(['fr', 'true'])
})
})
describe('Account menu keyboard and dismissal', () => {
it('wraps the arrow keys across every entry of the desktop popover', async () => {
await openAccountMenu('desktop UserMenu popover')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
await flushPromises()
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('wraps the arrow keys across every entry of the mobile sheet', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('applies a language with Enter and Space in the desktop popover and keeps it open', async () => {
await openAccountMenu('desktop UserMenu popover')
row('Français').focus()
press(row('Français'), 'Enter')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
row('English').focus()
press(row('English'), ' ')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['en', true])
})
it('renders the mobile language rows as native buttons so Enter and Space activate them', async () => {
await openAccountMenu('mobile AccountMenuSheet')
expect([row('English').tagName, row('Français').tagName]).toEqual(['BUTTON', 'BUTTON'])
row('Français').click()
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
})
it.each(surfaces)(
'closes with Escape without changing the language in the %s',
async (surface) => {
await openAccountMenu(surface)
row('Profile').focus()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
},
)
it('closes the desktop popover on a click outside without changing the language', async () => {
await openAccountMenu('desktop UserMenu popover')
clickOutside()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
it('closes the mobile sheet on a tap on the backdrop without changing the language', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
overlay.click()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
})
describe('Auth footer shows the language chip beside the theme chip', () => {
it.each([false, true])(
'renders both chips, aligned right, with belowSm=%s',
async (belowSm) => {
await mountAuthShell(belowSm)
const footer = document.body.querySelector('footer')!
const chips = footer.lastElementChild as HTMLElement
expect(footer).not.toBeNull()
expect(chips.className).toContain('gap-2')
expect(chips.children).toHaveLength(2)
expect(chips.children[0]).toBe(authChip())
expect(chips.children[1]!.textContent).toContain('System')
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-haspopup')).toBe('menu')
expect(authChip().getAttribute('aria-expanded')).toBe('false')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(authChip().querySelector('.iconify--lucide, svg')).not.toBeNull()
expect(authChip().className).toContain('border-accented')
expect(authChip().className).toContain('text-toned')
expect(authChip().className).toContain(belowSm ? 'h-11' : 'h-7')
expect(
document.body.querySelector('[role="combobox"][aria-label*="anguage"]'),
).toBeNull()
},
)
it('keeps the footer slot content to the left of the chips', async () => {
await mountAuthShell(false, { footer: '<a href="/register">Create an account</a>' })
const footer = document.body.querySelector('footer')!
const link = footer.querySelector('a')!
expect(footer.firstElementChild!.contains(link)).toBe(true)
expect(
footer.firstElementChild!.compareDocumentPosition(authChip()) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('reflects the stored language in the chip and its label', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect([authChip().textContent?.trim(), authChip().getAttribute('aria-label')]).toEqual([
'Français',
'Langue : Français',
])
})
})
describe('Auth language popover', () => {
async function openPopover() {
await mountAuthShell(false)
authChip().click()
await nextFrame()
}
it.each([
['English', 'en'],
['Français', 'fr'],
])('opens a named menu with two radio rows when the language is %s', async (name, code) => {
setLocale(code as 'en' | 'fr')
await openPopover()
expect(menu()!.getAttribute('aria-label')).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(authChip().getAttribute('aria-expanded')).toBe('true')
expect(authChip().className).toContain('bg-elevated')
expect(
rows().map((item) => [
item.getAttribute('role'),
item.textContent?.trim(),
item.getAttribute('lang'),
]),
).toEqual([
['menuitemradio', 'English', 'en'],
['menuitemradio', 'Français', 'fr'],
])
expect(checked(rows()).map((item) => item.textContent?.trim())).toEqual([name])
expect(menu()!.className).toContain('w-42')
expect(menu()!.className).toContain('p-1')
rows().forEach((item) => {
expect(item.className).toContain('h-8')
expect(item.className).toContain('text-base')
})
})
it.each(['Enter', ' ', 'ArrowDown'])(
'opens with %j on the chip and focuses the current row',
async (keyName) => {
await mountAuthShell(false)
authChip().focus()
press(authChip(), keyName)
await nextFrame()
expect(menu()).not.toBeNull()
expect(document.activeElement).toBe(row('English'))
},
)
it('moves between the two rows with the arrow keys and chooses with Enter', async () => {
await openPopover()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(row('Français'))
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(row('English'))
row('Français').focus()
press(row('Français'), 'Enter')
await nextFrame()
expect(locale.value).toBe('fr')
})
it('applies French on the whole screen, closes and returns focus to the chip', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
authChip().focus()
authChip().click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await nextFrame()
expect(locale.value).toBe('fr')
expect(document.documentElement.lang).toBe('fr')
expect(localStorage.getItem('renewcms-language')).toBe('fr')
expect(document.title).toBe('Se connecter · RenewCMS')
expect(document.body.textContent).toContain('Mot de passe')
expect(menu()).toBeNull()
expect(document.activeElement).toBe(authChip())
expect(authChip().textContent?.trim()).toBe('Français')
expect(request).not.toHaveBeenCalled()
})
it('dismisses with Escape, keeping the language and returning focus to the chip', async () => {
await openPopover()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([
menu(),
locale.value,
authChip().getAttribute('aria-expanded'),
document.activeElement,
]).toEqual([null, 'en', 'false', authChip()])
})
it('dismisses on a click outside, keeping the language', async () => {
await openPopover()
clickOutside()
await nextFrame()
expect([menu(), locale.value, authChip().getAttribute('aria-expanded')]).toEqual([
null,
'en',
'false',
])
})
})
describe('Each auth screen carries the language chip', () => {
const screens: [string, () => Promise<{ default: Component }>, string][] = [
['/login', () => import('@/views/LoginView.vue'), '/login'],
['/register', () => import('@/views/RegisterView.vue'), '/register'],
['/register/pending', () => import('@/views/PendingView.vue'), '/register/pending'],
['/register/validate', () => import('@/views/VerifyView.vue'), '/register/validate'],
[
'/register/reset/request',
() => import('@/views/ResetRequestView.vue'),
'/register/reset/request',
],
[
'/register/reset/validate',
() => import('@/views/ResetConfirmView.vue'),
'/register/reset/validate',
],
['/invite/accept', () => import('@/views/AcceptInviteView.vue'), '/invite/accept'],
]
it.each(screens)('%s', async (_name, load, path) => {
const session = useSessionStore()
session.session =
path === '/register/pending'
? { verified: false, username: 'marta', email: SIGNED_IN.email }
: null
session.status = 'ready'
await router.push(path)
const { default: view } = await load()
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(document.body.querySelector('[role="combobox"][aria-label*="anguage"]')).toBeNull()
})
it('keeps typed login values when the language changes', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="username"]').setValue('marta')
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([
(wrapper.find('input[autocomplete="username"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
]).toEqual(['marta', '/login'])
})
})
describe('Language persists across public and signed-in screens', () => {
it('keeps Français after signing in, in the account menu and back on the login chip', async () => {
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await openAccountMenu('desktop UserMenu popover')
expect(row('Français').getAttribute('aria-checked')).toBe('true')
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('restores a stored French language on the chip after a reload', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('still switches for the session when the storage write fails', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([locale.value, authChip().textContent?.trim()]).toEqual(['fr', 'Français'])
})
})
describe('Catalog parity for the new strings', () => {
it('translates Theme and Language: {0} and keeps the parameter', () => {
expect(messages['Theme']).toBe('Thème')
expect(messages['Language: {0}']).toBe('Langue : {0}')
expect(messages['Language: {0}']).toContain('{0}')
})
it('never translates the native language names', () => {
expect(Object.keys(messages)).not.toContain('English')
expect(Object.keys(messages)).not.toContain('Français')
})
})
describe('Radio role patching releases its observer', () => {
async function mountProbe() {
const { defineComponent, h, ref } = await import('vue')
const { useLanguageMenuRadios } = await import('@/composables/useRadioMenuRoles')
const open = ref(false)
const probe = defineComponent({
setup() {
useLanguageMenuRadios(open)
return () => h('div')
},
})
return { open, probe: mount(probe) }
}
it('observes only while open and disconnects on close', async () => {
const observe = vi.spyOn(MutationObserver.prototype, 'observe')
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
expect(observe).not.toHaveBeenCalled()
open.value = true
await flushPromises()
expect(observe).toHaveBeenCalledTimes(1)
expect(disconnect).not.toHaveBeenCalled()
open.value = false
await flushPromises()
expect(disconnect).toHaveBeenCalledTimes(1)
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('disconnects when unmounted while open', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
open.value = true
await flushPromises()
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
})
@@ -33,8 +33,7 @@ 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
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -78,9 +77,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll(
'[data-slot="root"][data-orientation]',
)
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"]')
@@ -97,14 +94,12 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
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',
)
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 () => {
it('renders as the last chip, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
@@ -112,8 +107,8 @@ describe('The theme select is right-aligned in the footer with no chevron', () =
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)
const chips = footer.element.lastElementChild as HTMLElement
expect(chips.lastElementChild).toBe(select.element)
expect(select.text()).toContain('System')
@@ -146,31 +141,19 @@ describe('The alert renders inside the card, under the description', () => {
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 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 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('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-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
},
)
})