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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user