feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
username: string
displayName?: string
email?: string
}>()
const emit = defineEmits<{
logout: []
}>()
const open = defineModel<boolean>('open', { default: false })
const {
colorMode,
name,
initials,
onProfile,
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
function goToProfile() {
open.value = false
navigateToProfile()
}
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
open.value = false
emit('logout')
}
function moveFocus(event: KeyboardEvent) {
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
}
const current = items.indexOf(document.activeElement as HTMLElement)
const lastIndex = items.length - 1
const targets: Record<string, number> = {
ArrowDown: current >= lastIndex ? 0 : current + 1,
ArrowUp: current <= 0 ? lastIndex : current - 1,
Home: 0,
End: lastIndex,
}
const target = targets[event.key]
if (target === undefined) {
return
}
event.preventDefault()
items[target]?.focus()
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:close="false"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
aria-hidden="true"
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
name
}}</span>
<span class="truncate text-muted">{{ email }}</span>
</div>
</div>
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-circle-user"
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<USeparator class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-log-out"
:class="ITEM_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
</UButton>
</div>
</template>
</USlideover>
</template>
+7 -1
View File
@@ -7,7 +7,7 @@ const props = withDefaults(
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
variant?: 'inline' | 'footer' | 'field'
plainText?: boolean
}>(),
{
@@ -113,6 +113,12 @@ const isErrorStatus = computed(() => status.value === 'over' || status.value ===
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<span
v-else-if="variant === 'field'"
class="font-mono text-xs leading-tight"
:class="overBy > 0 ? 'text-error' : 'text-muted'"
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-xs"
+28 -11
View File
@@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
@@ -14,10 +15,12 @@ const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
renderWhileChecking?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
renderWhileChecking: false,
},
)
@@ -63,6 +66,15 @@ const navUi = computed(() => ({
linkLeadingIcon: 'size-4',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
function openAccountSheet() {
sidebarOpen.value = false
accountSheetOpen.value = true
}
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
function onRetry() {
session.retry()
@@ -82,11 +94,10 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
>Skip to content</a
>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
@@ -115,8 +126,11 @@ async function onLogout() {
<template #footer>
<UserMenu
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed"
@logout="onLogout"
@open-sheet="openAccountSheet"
/>
</template>
</UDashboardSidebar>
@@ -164,26 +178,29 @@ async function onLogout() {
<template #body>
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<AppBoot v-if="showBoot" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
<slot />
</div>
</div>
</template>
</UDashboardPanel>
<AccountMenuSheet
v-model:open="accountSheetOpen"
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
/>
</UDashboardGroup>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
</div>
<div
v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
>
<slot name="footer" />
</div>
</section>
</template>
+64 -31
View File
@@ -1,43 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
useColorMode,
type ColorModePreference,
} from '@/composables/useColorMode'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const DESKTOP_QUERY = '(min-width: 64rem)'
const props = defineProps<{
username: string
displayName?: string
email?: string
collapsed: boolean
}>()
const emit = defineEmits<{
logout: []
'open-sheet': []
}>()
const colorMode = useColorMode()
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
const chipActive = computed(() => open.value || onProfile.value)
const triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined,
props.collapsed ? `${name.value} — account menu` : undefined,
)
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
const chipClass = computed(() => [
'h-10 gap-2.5 px-1.5 font-normal',
props.collapsed ? 'justify-center' : 'justify-start',
chipActive.value
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 hover:text-primary-900 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'text-highlighted',
])
function onUpdateOpen(value: boolean) {
if (isDesktop.value) {
open.value = value
return
}
if (value) {
emit('open-sheet')
}
}
const items = computed(() => [
[{ type: 'label' as const, label: props.username }],
[
{
type: 'label' as const,
label: name.value,
slot: 'account-header' as const,
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
icon: 'i-lucide-circle-user',
class: onProfile.value ? 'bg-elevated' : undefined,
onSelect: goToProfile,
},
],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
@@ -51,6 +73,7 @@ const items = computed(() => [
[
{
label: 'Log out',
icon: 'i-lucide-log-out',
onSelect: () => emit('logout'),
},
],
@@ -58,17 +81,21 @@ const items = computed(() => [
</script>
<template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
<UDropdownMenu
:open="open"
:items="items"
:content="{ side: 'top', align: 'start' }"
:ui="{ content: 'w-56 p-1' }"
@update:open="onUpdateOpen"
>
<UButton
color="neutral"
variant="ghost"
block
:class="[
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
collapsed ? 'justify-center' : 'justify-start',
]"
title="Account"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
@@ -78,12 +105,18 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback:
'text-[0.6875rem] font-semibold text-primary-900',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
<span v-if="!collapsed" class="truncate">{{ username }}</span>
<span v-if="!collapsed" class="truncate font-medium">{{ name }}</span>
</UButton>
<template #account-header>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
</UDropdownMenu>
</template>
@@ -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')
})
})
+259 -130
View File
@@ -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()
})
})
+30
View File
@@ -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