feat: home screen and navigation design
This commit is contained in:
1 parent
30a4df47b1
commit
930b836241
24 files changed
+1774
-19
No files matched your search
@@ -14,11 +14,15 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function mountShell(path: string) {
|
||||
async function mountShell(path: string, username = 'marta') {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true }) })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push(path)
|
||||
const wrapper = mount(DashboardShell, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
@@ -29,6 +33,7 @@ async function mountShell(path: string) {
|
||||
|
||||
afterEach(async () => {
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the three navigation destinations', () => {
|
||||
@@ -66,3 +71,86 @@ describe('DashboardShell shows the offline banner when the browser reports offli
|
||||
onLineSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () => {
|
||||
it('renders the logo before the wordmark text, neither as a link', async () => {
|
||||
const wrapper = await mountShell('/home')
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
|
||||
expect(header.text()).toContain('RenewCMS')
|
||||
expect(header.find('a').exists()).toBe(false)
|
||||
|
||||
const headerHtml = header.html()
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('The sidebar collapse trigger toggles the rail and exposes aria-expanded', () => {
|
||||
it('flips the rail and aria-expanded on repeated activation', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = () =>
|
||||
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
collapseButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
collapseButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful logout clears the session and navigates to /login', () => {
|
||||
it('calls POST /v1/auth/logout, clears the session and navigates to /login', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const logOutItem = () =>
|
||||
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(el) => el.textContent?.trim() === 'Log out',
|
||||
) as HTMLElement
|
||||
|
||||
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
|
||||
logOutItem().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
|
||||
'/v1/auth/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
|
||||
it('hides visible text but keeps accessible names for the logo and the user menu', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
|
||||
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
|
||||
expect(header.textContent).not.toContain('RenewCMS')
|
||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).not.toContain('marta')
|
||||
|
||||
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
|
||||
function mountMenu(props: { username: string; collapsed: boolean }) {
|
||||
return mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function findTriggerButton() {
|
||||
return document.body.querySelector('button')
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
findTriggerButton()?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findMenuItemsByRole(role: string) {
|
||||
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
|
||||
}
|
||||
|
||||
function findCheckboxItem(label: string) {
|
||||
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
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')
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
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')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user