feat(SPA): profile page, display name and the new account menu
This commit is contained in:
1 parent
55525c06d6
commit
b0f7b7892b
29 files changed
+3166
-469
No files matched your search
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
|
||||
const wrapper = mountCounter(4318)
|
||||
|
||||
expect(wrapper.text()).toContain('4 318 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
|
||||
const wrapper = mountCounter(9240)
|
||||
|
||||
expect(wrapper.text()).toContain('9 240 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('characters over')
|
||||
})
|
||||
})
|
||||
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
|
||||
const wrapper = mountCounter(10214)
|
||||
|
||||
expect(wrapper.text()).toContain('10 214 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain(
|
||||
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The field variant is a bare mono count', () => {
|
||||
function mountField(value: number) {
|
||||
return mount(CharacterCounter, {
|
||||
props: { value, max: 60, variant: 'field' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
[0, 'text-muted'],
|
||||
[60, 'text-muted'],
|
||||
[61, 'text-error'],
|
||||
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
|
||||
const wrapper = mountField(value)
|
||||
|
||||
expect(wrapper.text()).toBe(`${value} / 60`)
|
||||
expect(wrapper.classes()).toContain(colorClass)
|
||||
expect(wrapper.classes()).toContain('font-mono')
|
||||
expect(wrapper.find('svg').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function mountShell(path: string, username = 'marta') {
|
||||
interface ShellOptions {
|
||||
username?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
let mountedShell: VueWrapper | null = null
|
||||
|
||||
async function mountShell(path: string, options: ShellOptions = {}) {
|
||||
const {
|
||||
username = 'marta',
|
||||
displayName = '',
|
||||
email = 'marta@renew.example',
|
||||
width = DESKTOP_WIDTH,
|
||||
} = options
|
||||
localStorage.clear()
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push(path)
|
||||
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
mountedShell = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mountedShell?.unmount()
|
||||
mountedShell = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The account chip shows the display name and no item is current on /profile', () => {
|
||||
it('shows the display name and its initials in the sidebar chip', async () => {
|
||||
await mountShell('/home', { displayName: 'Marta Alves' })
|
||||
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).toContain('Marta Alves')
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
|
||||
it('keeps the collapsed accessible name on the display name', async () => {
|
||||
await mountShell('/home', { displayName: 'Marta Alves' })
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
})
|
||||
|
||||
it('marks none of the four navigation items current on /profile', async () => {
|
||||
const wrapper = await mountShell('/profile')
|
||||
|
||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(4)
|
||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account menu: opened from the drawer on mobile', () => {
|
||||
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
const accountRow = document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
expect(accountRow).not.toBeNull()
|
||||
accountRow.click()
|
||||
await flushPromises()
|
||||
|
||||
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
|
||||
expect(sheets).toHaveLength(1)
|
||||
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
|
||||
expect(
|
||||
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
|
||||
).not.toBeNull()
|
||||
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"]'),
|
||||
).map((item) => item.textContent?.trim())
|
||||
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
|
||||
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
|
||||
expect(item.className).toContain('h-12'),
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to /profile and closes the sheet from Profile', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
;(
|
||||
document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
).click()
|
||||
await flushPromises()
|
||||
|
||||
const profile = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
profile.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.querySelector('[role="menu"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('logs out from the sheet', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
;(
|
||||
document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
).click()
|
||||
await flushPromises()
|
||||
|
||||
const logOut = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Log out')!
|
||||
logOut.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
|
||||
'/v1/auth/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import router from '@/router'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
function mountMenu(props: { username: string; collapsed: boolean }) {
|
||||
interface MenuProps {
|
||||
username: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const baseProps: MenuProps = {
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
email: 'marta@renew.example',
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
return mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
props: { ...baseProps, ...props },
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
function findTriggerButton() {
|
||||
return document.body.querySelector('button')
|
||||
function chip() {
|
||||
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
findTriggerButton()?.click()
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findMenuItemsByRole(role: string) {
|
||||
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
|
||||
function menu() {
|
||||
return document.body.querySelector('[role="menu"]') as HTMLElement | null
|
||||
}
|
||||
|
||||
function findCheckboxItem(label: string) {
|
||||
return findMenuItemsByRole('menuitemcheckbox').find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
)
|
||||
function itemsIn(container: ParentNode) {
|
||||
return Array.from(
|
||||
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
|
||||
) as HTMLElement[]
|
||||
}
|
||||
|
||||
function itemLabels() {
|
||||
return itemsIn(menu()!).map((el) => el.textContent?.trim())
|
||||
}
|
||||
|
||||
function findItem(label: string) {
|
||||
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
|
||||
}
|
||||
|
||||
function activate(element: HTMLElement) {
|
||||
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
|
||||
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
describe('The chip opens the account menu instead of logging out', () => {
|
||||
it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
|
||||
const wrapper = await mountMenu()
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('MA')
|
||||
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(wrapper.text()).toContain('marta')
|
||||
expect(chip().getAttribute('aria-haspopup')).toBe('menu')
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(chip().getAttribute('title')).toBe('Account')
|
||||
expect(chip().textContent).toContain('marta')
|
||||
expect(wrapper.emitted('logout')).toBeUndefined()
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
|
||||
(el) => el.textContent?.trim(),
|
||||
)
|
||||
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
|
||||
|
||||
expect(groups[2].textContent?.trim()).toBe('Log out')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('derives initials from the first letter of the first two words for a multi-word username', () => {
|
||||
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('JD')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('checks exactly the item matching the current color-mode preference', async () => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(menu()).not.toBeNull()
|
||||
expect(wrapper.emitted('logout')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selecting a color-mode item updates the preference and its checked state', () => {
|
||||
it.each([
|
||||
['system', 'light'],
|
||||
['light', 'dark'],
|
||||
['dark', 'system'],
|
||||
])('moves from %s to %s', async (from, to) => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = from as 'system' | 'light' | 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
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 () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
const label = to.charAt(0).toUpperCase() + to.slice(1)
|
||||
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
||||
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('pointerup', { bubbles: true }),
|
||||
)
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe(to)
|
||||
|
||||
await openMenu()
|
||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful logout clears the session and navigates to /login', () => {
|
||||
it('emits logout when the user activates Log out', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
const logOutItem = Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out')
|
||||
;(logOutItem as HTMLElement)?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('logout')).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
|
||||
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('marta')
|
||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
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(
|
||||
document.body.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
|
||||
).toBe(true)
|
||||
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows the display name in the header and the chip when there is one', async () => {
|
||||
await mountMenu({ displayName: 'Marta Alves' })
|
||||
await openMenu()
|
||||
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(menu()!.textContent).toContain('Marta Alves')
|
||||
expect(menu()!.textContent).toContain('marta@renew.example')
|
||||
})
|
||||
|
||||
it('does not highlight Profile away from /profile', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives the chip the sand fill only while the menu is open', async () => {
|
||||
await mountMenu()
|
||||
|
||||
expect(chip().className).not.toContain('bg-secondary-200')
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
expect(chip().className).toContain('bg-secondary-200')
|
||||
expect(chip().className).toContain('text-primary-900')
|
||||
})
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
describe('The chip is sand while on /profile even with the menu closed', () => {
|
||||
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
|
||||
await mountMenu({}, '/profile')
|
||||
|
||||
expect(chip().className).toContain('bg-secondary-200')
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
|
||||
expect(chip().className).not.toContain('bg-secondary-200')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Closing the menu', () => {
|
||||
it('closes on Escape and returns focus to the chip', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(menu()).toBeNull()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(chip())
|
||||
})
|
||||
|
||||
it('closes on a click outside', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(menu()).toBeNull()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('moves between items with the arrow keys', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
const first = itemsIn(menu()!)[0]!
|
||||
first.focus()
|
||||
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
|
||||
|
||||
document.activeElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu items', () => {
|
||||
it('navigates to /profile and closes the menu from Profile', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
activate(findItem('Profile'))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
|
||||
it('highlights Profile on /profile and only closes when activated there', async () => {
|
||||
await mountMenu({}, '/profile')
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
|
||||
|
||||
activate(findItem('Profile'))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
|
||||
it('emits logout from Log out', async () => {
|
||||
const wrapper = await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
activate(findItem('Log out'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('logout')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme choice stays in the menu', () => {
|
||||
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = 'system'
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
activate(findItem('Dark'))
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe('dark')
|
||||
expect(menu()).toBeNull()
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
|
||||
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar initials', () => {
|
||||
it.each([
|
||||
['marta', '', 'MA', 'marta'],
|
||||
['marta', 'Marta Alves', 'MA', 'Marta Alves'],
|
||||
['marta', 'Marta Alves Costa', 'MA', 'Marta Alves Costa'],
|
||||
['ines', 'Inès Duarte', 'ID', 'Inès Duarte'],
|
||||
['elodie', 'Élodie Martin', 'ÉM', 'Élodie Martin'],
|
||||
['marta', 'Marta', 'MA', 'Marta'],
|
||||
['m.alves', '', 'M.', 'm.alves'],
|
||||
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
|
||||
await mountMenu({ username, displayName })
|
||||
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
|
||||
expect(chip().textContent).toContain(shown)
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(menu()!.textContent).toContain(shown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The collapsed rail keeps the menu and an accessible name', () => {
|
||||
it('shows only the avatar, names the chip and opens the same menu', async () => {
|
||||
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
|
||||
|
||||
expect(chip().textContent).not.toContain('Marta Alves')
|
||||
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Below lg the chip hands the menu to the bottom sheet', () => {
|
||||
it('emits open-sheet instead of opening the popover', async () => {
|
||||
setViewportWidth(MOBILE_WIDTH)
|
||||
const wrapper = await mountMenu()
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
const REM_PX = 16
|
||||
|
||||
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
|
||||
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
|
||||
}
|
||||
|
||||
export function setViewportWidth(width: number) {
|
||||
window.matchMedia = ((query: string) => {
|
||||
const min = widthLimit(query, 'min-width')
|
||||
const max = widthLimit(query, 'max-width')
|
||||
const matches = (min === null || width >= min) && (max === null || width <= max)
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}
|
||||
}) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
export const DESKTOP_WIDTH = 1440
|
||||
export const MOBILE_WIDTH = 390
|
||||
Reference in new issue
Block a user