fix: language buttons real design / conformance
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user