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
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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