feat: home screen and navigation design

This commit is contained in:
fsylvain committed 2026-09-26 17:46:42 +02:00
1 parent 30a4df47b1
commit 930b836241
24 files changed
+1774 -19

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md'
}>(),
{
size: 'md',
},
)
</script>
<template>
<UBadge v-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
</UBadge>
<UBadge
v-else
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
class="px-2"
>
Draft
</UBadge>
</template>
+87 -11
View File
@@ -1,13 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
import AppBoot from '@/components/AppBoot.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UserMenu from '@/components/UserMenu.vue'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
@@ -16,39 +21,111 @@ const navItems = [
]
const pageTitle = computed(() => (route.meta.title as string) ?? '')
const sidebarCollapsed = ref(false)
const loggingOut = ref(false)
function onRetry() {
session.retry()
}
async function onLogout() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
</script>
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4">
<UDashboardSidebar
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
:default-size="14"
:collapsed-size="3.5"
:ui="{
root: 'min-w-14 p-3',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
}"
>
<template #header>
<span class="font-semibold">RenewCMS</span>
<AppLogo :size="26" />
<span
v-if="!sidebarCollapsed"
aria-hidden="true"
class="text-base font-semibold text-highlighted"
>RenewCMS</span
>
</template>
<UNavigationMenu orientation="vertical" :items="navItems" aria-label="Main" />
<UNavigationMenu
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="{
list: 'flex flex-col gap-0.5',
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
linkLeadingIcon: 'size-4',
}"
/>
<template #footer>
<span>Log out</span>
<UserMenu
:username="session.session?.username ?? ''"
:collapsed="sidebarCollapsed"
@logout="onLogout"
/>
</template>
</UDashboardSidebar>
<UDashboardPanel>
<UDashboardPanel
:ui="{ body: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' }"
>
<template #header>
<UDashboardNavbar :title="pageTitle">
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
left: 'min-w-0 flex-1 gap-1 lg:gap-2',
title: 'text-lg lg:sr-only',
toggle: 'size-11 justify-center',
}"
>
<template #leading>
<UDashboardSidebarCollapse />
<UDashboardSidebarCollapse
color="neutral"
variant="ghost"
size="sm"
class="-mx-1.5 text-muted"
:aria-expanded="!sidebarCollapsed"
data-testid="sidebar-collapse"
/>
<span
aria-hidden="true"
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
/>
<UBreadcrumb
:items="[{ label: pageTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
<UBreadcrumb :items="[{ label: pageTitle }]" />
</template>
<template #body>
@@ -62,7 +139,6 @@ function onRetry() {
@retry="onRetry"
/>
<div v-else>
<h1>{{ pageTitle }}</h1>
<slot />
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from 'vue'
const open = defineModel<boolean>('open', { default: false })
const title = ref('')
const body = ref('')
function onCancel() {
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="New article">
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput v-model="title" placeholder="Article title" class="w-full" />
</UFormField>
<UFormField label="Body" name="body">
<UTextarea v-model="body" placeholder="Start writing…" class="w-full" />
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel">Cancel</UButton>
<UButton disabled>Create article</UButton>
</div>
</template>
</UModal>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
const props = defineProps<{
username: string
collapsed: boolean
}>()
const emit = defineEmits<{
logout: []
}>()
const colorMode = useColorMode()
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 triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined,
)
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
const items = computed(() => [
[{ type: 'label' as const, label: props.username }],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: colorMode.preference === option.value,
onUpdateChecked: (checked: boolean) => {
if (checked) {
colorMode.preference = option.value
}
},
})),
[
{
label: 'Log out',
onSelect: () => emit('logout'),
},
],
])
</script>
<template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
<UButton
color="neutral"
variant="ghost"
block
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
trailing-icon="i-lucide-chevrons-up-down"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
<UAvatar
aria-hidden="true"
:text="initials"
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
<span v-if="!collapsed" class="truncate">{{ username }}</span>
</UButton>
</UDropdownMenu>
</template>
@@ -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()
})
})