879 lines
30 KiB
TypeScript
879 lines
30 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
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 { useColorMode } from '@/composables/useColorMode'
|
|
import DashboardShell from '../DashboardShell.vue'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
|
import type { Access } from '@/lib/permissions'
|
|
import { accessFor, customAccess, writerAccess } from '@/test/access'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return {
|
|
...actual,
|
|
useApi: vi.fn(),
|
|
}
|
|
})
|
|
|
|
interface ShellOptions {
|
|
username?: string
|
|
displayName?: string
|
|
email?: string
|
|
role?: string
|
|
access?: Access
|
|
width?: number
|
|
}
|
|
|
|
let mountedShell: VueWrapper | null = null
|
|
|
|
async function mountShell(path: string, options: ShellOptions = {}) {
|
|
const {
|
|
username = 'marta',
|
|
displayName = '',
|
|
email = 'marta@renew.example',
|
|
role = 'noschema',
|
|
access,
|
|
width = DESKTOP_WIDTH,
|
|
} = options
|
|
localStorage.clear()
|
|
setViewportWidth(width)
|
|
setActivePinia(createPinia())
|
|
vi.mocked(useApi).mockReturnValue({
|
|
getMe: vi.fn().mockResolvedValue({
|
|
verified: true,
|
|
username,
|
|
email,
|
|
displayName,
|
|
access: access ?? accessFor(role),
|
|
}),
|
|
request: vi.fn().mockResolvedValue(undefined),
|
|
})
|
|
await router.push(path)
|
|
const wrapper = mount(DashboardShell, {
|
|
attachTo: document.body,
|
|
global: {
|
|
plugins: [router, uiPlugin],
|
|
},
|
|
})
|
|
mountedShell = wrapper
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
afterEach(async () => {
|
|
mountedShell?.unmount()
|
|
mountedShell = null
|
|
await router.push('/login')
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('DashboardShell renders the eight navigation destinations', () => {
|
|
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
|
const wrapper = await mountShell('/article')
|
|
|
|
const hrefs = wrapper
|
|
.findAll('nav[aria-label="Main"] a')
|
|
.map((link) => link.attributes('href'))
|
|
expect(hrefs).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/media',
|
|
'/authors',
|
|
'/users',
|
|
'/activity',
|
|
'/integration',
|
|
'/settings',
|
|
])
|
|
|
|
const home = wrapper.find('a[href="/home"]')
|
|
const articles = wrapper.find('a[href="/article"]')
|
|
const users = wrapper.find('a[href="/users"]')
|
|
const integration = wrapper.find('a[href="/integration"]')
|
|
|
|
expect(articles.attributes('aria-current')).toBe('page')
|
|
expect(home.attributes('aria-current')).toBeUndefined()
|
|
expect(users.attributes('aria-current')).toBeUndefined()
|
|
expect(integration.attributes('aria-current')).toBeUndefined()
|
|
})
|
|
|
|
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
|
|
await mountShell('/settings', { width: MOBILE_WIDTH })
|
|
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
|
|
const links = Array.from(
|
|
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
|
).filter(
|
|
(link) =>
|
|
link.getAttribute('href') === '/settings' ||
|
|
!link.getAttribute('href')?.startsWith('/settings/'),
|
|
)
|
|
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/media',
|
|
'/authors',
|
|
'/users',
|
|
'/activity',
|
|
'/integration',
|
|
'/settings',
|
|
])
|
|
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
|
expect(links[7].getAttribute('aria-current')).toBe('page')
|
|
})
|
|
|
|
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
|
|
const wrapper = await mountShell('/media')
|
|
|
|
const media = wrapper.find('a[href="/media"]')
|
|
expect(media.text()).toContain('Media')
|
|
expect(media.attributes('aria-current')).toBe('page')
|
|
expect(media.find('svg, [class*="lucide"]').exists()).toBe(true)
|
|
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
|
|
expect(wrapper.find('a[href="/users"]').attributes('aria-current')).toBeUndefined()
|
|
wrapper.unmount()
|
|
mountedShell = null
|
|
document.body.innerHTML = ''
|
|
|
|
await mountShell('/media', { width: MOBILE_WIDTH })
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
const drawerMedia = document.body.querySelector(
|
|
'[role="dialog"] nav[aria-label="Main"] a[href="/media"]',
|
|
)
|
|
expect(drawerMedia?.getAttribute('aria-current')).toBe('page')
|
|
})
|
|
|
|
it('marks Users with aria-current on /users', async () => {
|
|
const wrapper = await mountShell('/users')
|
|
|
|
const users = wrapper.find('a[href="/users"]')
|
|
expect(users.text()).toContain('Users')
|
|
expect(users.attributes('aria-current')).toBe('page')
|
|
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
|
it('shows the banner while offline and hides it once back online', async () => {
|
|
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
|
|
|
const wrapper = await mountShell('/article')
|
|
|
|
expect(wrapper.text()).toContain("You're offline")
|
|
|
|
onLineSpy.mockReturnValue(true)
|
|
window.dispatchEvent(new Event('online'))
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).not.toContain("You're offline")
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
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 eight navigation items current on /profile', async () => {
|
|
const wrapper = await mountShell('/profile')
|
|
|
|
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
|
expect(links).toHaveLength(8)
|
|
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="menuitemradio"]'),
|
|
).map((item) => item.textContent?.trim())
|
|
expect(labels).toEqual([
|
|
'Profile',
|
|
'System',
|
|
'Light',
|
|
'Dark',
|
|
'English',
|
|
'Français',
|
|
'Log out',
|
|
])
|
|
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]').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('applies a theme choice and keeps the sheet open', async () => {
|
|
const colorMode = useColorMode()
|
|
colorMode.preference = 'system'
|
|
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 dark = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemradio"]'),
|
|
).find((item) => item.textContent?.trim() === 'Dark')!
|
|
dark.click()
|
|
await flushPromises()
|
|
|
|
expect(colorMode.preference).toBe('dark')
|
|
expect(document.body.querySelector('[role="menu"]')).not.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')
|
|
})
|
|
})
|
|
|
|
describe('DashboardShell Settings expands to the Media subpages', () => {
|
|
it.each(['owner', 'admin'])(
|
|
'shows Media: Variants and Cache under Settings for %s on /settings',
|
|
async (role) => {
|
|
const wrapper = await mountShell('/settings', { role })
|
|
|
|
const nav = wrapper.find('nav[aria-label="Main"]')
|
|
expect(nav.text()).toContain('Media')
|
|
const variants = nav.find('a[href="/settings/media/variants"]')
|
|
expect(variants.text()).toContain('Variants')
|
|
expect(variants.attributes('aria-current')).toBeUndefined()
|
|
const cache = nav.find('a[href="/settings/media/cache"]')
|
|
expect(cache.text()).toContain('Cache')
|
|
expect(cache.attributes('aria-current')).toBeUndefined()
|
|
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page')
|
|
},
|
|
)
|
|
|
|
it('marks Variants current and Settings not current on the variants page', async () => {
|
|
const wrapper = await mountShell('/settings/media/variants', {
|
|
role: 'admin',
|
|
})
|
|
|
|
const nav = wrapper.find('nav[aria-label="Main"]')
|
|
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
|
|
'page',
|
|
)
|
|
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
|
|
})
|
|
|
|
it('marks Cache current and Settings not current on the cache page', async () => {
|
|
const wrapper = await mountShell('/settings/media/cache', {
|
|
role: 'admin',
|
|
})
|
|
|
|
const nav = wrapper.find('nav[aria-label="Main"]')
|
|
expect(nav.find('a[href="/settings/media/cache"]').attributes('aria-current')).toBe('page')
|
|
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
|
|
})
|
|
|
|
it('shows no Media subpages without an owner or admin role', async () => {
|
|
const wrapper = await mountShell('/settings', { role: 'user' })
|
|
|
|
const nav = wrapper.find('nav[aria-label="Main"]')
|
|
expect(nav.find('a[href="/settings/media/variants"]').exists()).toBe(false)
|
|
expect(nav.text()).not.toContain('Cache')
|
|
})
|
|
})
|
|
|
|
describe('DashboardShell selected nav rows use sand with on-sand text', () => {
|
|
const SAND = 'bg-(--rc-sand)'
|
|
const ON_SAND = 'text-(--rc-on-sand)'
|
|
const STATES = ['', 'hover:', 'active:', 'focus-visible:']
|
|
const selectedClasses = STATES.flatMap((state) => [`${state}${SAND}`, `${state}${ON_SAND}`])
|
|
const SCOPED_BOLD = '[li[data-state=open]>&:not([aria-current=page])]:font-medium'
|
|
const UNSCOPED_BOLD = '[li[data-state=open]>&]:font-medium'
|
|
|
|
it.each([
|
|
'/settings',
|
|
'/settings/media/variants',
|
|
'/settings/media/cache',
|
|
'/home',
|
|
'/users',
|
|
'/activity',
|
|
])('fills the selected row on %s with sand and on-sand in every state', async (path) => {
|
|
const wrapper = await mountShell(path, { role: 'owner' })
|
|
|
|
const nav = wrapper.find('nav[aria-label="Main"]')
|
|
const selected = nav.find(`a[href="${path}"]`)
|
|
expect(selected.attributes('aria-current')).toBe('page')
|
|
expect(selected.classes()).toEqual(expect.arrayContaining(selectedClasses))
|
|
expect(selected.classes()).toContain('focus-visible:outline-(--ui-primary)')
|
|
expect(selected.find('[class*="--rc-on-sand"]').exists()).toBe(true)
|
|
|
|
const unselected = nav
|
|
.findAll('a')
|
|
.filter((link) => link.attributes('aria-current') === undefined)
|
|
expect(unselected.length).toBeGreaterThan(0)
|
|
for (const link of unselected) {
|
|
for (const cls of selectedClasses) {
|
|
expect(link.classes()).not.toContain(cls)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('keeps the unfilled Settings parent bold when a child is selected', async () => {
|
|
const wrapper = await mountShell('/settings/media/variants', {
|
|
role: 'owner',
|
|
})
|
|
|
|
const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
|
|
expect(parent.attributes('aria-current')).toBeUndefined()
|
|
expect(parent.classes()).not.toContain(SAND)
|
|
expect(parent.classes()).toContain(SCOPED_BOLD)
|
|
expect(parent.classes()).not.toContain(UNSCOPED_BOLD)
|
|
})
|
|
|
|
it('scopes the open-parent bold rule away from the selected row', async () => {
|
|
const wrapper = await mountShell('/settings', { role: 'owner' })
|
|
|
|
const selected = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
|
|
expect(selected.classes()).toContain(SCOPED_BOLD)
|
|
expect(selected.classes()).not.toContain(UNSCOPED_BOLD)
|
|
expect(selected.classes()).not.toContain('[li[data-state=open]>&]:text-highlighted')
|
|
})
|
|
|
|
it('draws drawer rows 44 px tall with the same selected treatment', async () => {
|
|
await mountShell('/settings', { role: 'owner', width: MOBILE_WIDTH })
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
|
|
const settings = document.body.querySelector(
|
|
'[role="dialog"] nav[aria-label="Main"] a[href="/settings"]',
|
|
) as HTMLElement
|
|
expect(settings.classList.contains('max-lg:h-11')).toBe(true)
|
|
for (const cls of selectedClasses) {
|
|
expect(settings.classList.contains(cls)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
const mainCss = readFileSync(join(import.meta.dirname, '../../assets/main.css'), 'utf8')
|
|
|
|
function declarationsOf(selector: string) {
|
|
const start = mainCss.indexOf(`\n${selector} {`)
|
|
const end = mainCss.indexOf('\n}', start)
|
|
const block = mainCss.slice(start, end)
|
|
return new Map(
|
|
[...block.matchAll(/^\t(--[a-z0-9-]+): ([^;]+);$/gm)].map((match) => [
|
|
match[1]!,
|
|
match[2]!,
|
|
]),
|
|
)
|
|
}
|
|
|
|
function resolveToken(declarations: Map<string, string>, token: string) {
|
|
const value = declarations.get(token) ?? declarationsOf(':root').get(token)!
|
|
const alias = /^var\((--[a-z0-9-]+)\)$/.exec(value)
|
|
if (!alias) {
|
|
return value
|
|
}
|
|
return declarations.get(alias[1]!) ?? resolveFromTheme(alias[1]!)
|
|
}
|
|
|
|
function resolveFromTheme(token: string) {
|
|
return new RegExp(`^\t${token}: ([^;]+);$`, 'm').exec(mainCss)![1]!
|
|
}
|
|
|
|
const tokenRows: [string, 'light' | 'dark', string][] = [
|
|
['--rc-sand', 'light', '#e8dfd1'],
|
|
['--rc-sand', 'dark', '#d4c5b0'],
|
|
['--rc-on-sand', 'light', '#111111'],
|
|
['--rc-on-sand', 'dark', '#111111'],
|
|
['--rc-success-soft', 'light', '#eaefea'],
|
|
['--rc-success-soft', 'dark', '#1f241f'],
|
|
['--rc-info-soft', 'light', '#ebeef0'],
|
|
['--rc-info-soft', 'dark', '#202428'],
|
|
['--rc-warning-soft', 'light', '#f0ede6'],
|
|
['--rc-warning-soft', 'dark', '#2a251e'],
|
|
['--rc-error-soft', 'light', '#f3eae9'],
|
|
['--rc-error-soft', 'dark', '#2c201f'],
|
|
['--rc-overlay', 'light', 'rgb(10 10 10 / 0.4)'],
|
|
['--rc-overlay', 'dark', 'rgb(0 0 0 / 0.6)'],
|
|
['--rc-shadow-pop', 'light', '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)'],
|
|
['--rc-shadow-pop', 'dark', '0 4px 16px rgb(0 0 0 / 0.5)'],
|
|
['--rc-shadow-modal', 'light', '0 16px 48px rgb(17 17 17 / 0.18)'],
|
|
['--rc-shadow-modal', 'dark', '0 16px 48px rgb(0 0 0 / 0.6)'],
|
|
]
|
|
|
|
const lightUiTokens = {
|
|
'--ui-bg': 'var(--color-white)',
|
|
'--ui-bg-muted': 'var(--color-chalk-50)',
|
|
'--ui-bg-elevated': 'var(--color-chalk-100)',
|
|
'--ui-bg-accented': 'var(--color-chalk-200)',
|
|
'--ui-bg-inverted': 'var(--color-chalk-900)',
|
|
'--ui-text-dimmed': 'var(--color-chalk-400)',
|
|
'--ui-text-muted': 'var(--color-chalk-500)',
|
|
'--ui-text-toned': 'var(--color-chalk-600)',
|
|
'--ui-text': 'var(--color-chalk-800)',
|
|
'--ui-text-highlighted': 'var(--color-chalk-900)',
|
|
'--ui-text-inverted': 'var(--color-white)',
|
|
'--ui-border': 'var(--color-chalk-200)',
|
|
'--ui-border-muted': 'var(--color-chalk-100)',
|
|
'--ui-border-accented': 'var(--color-chalk-450)',
|
|
'--ui-border-inverted': 'var(--color-chalk-900)',
|
|
'--ui-primary': 'var(--ui-color-primary-900)',
|
|
'--ui-secondary': 'var(--ui-color-secondary-700)',
|
|
'--ui-success': 'var(--ui-color-success-700)',
|
|
'--ui-info': 'var(--ui-color-info-700)',
|
|
'--ui-warning': 'var(--ui-color-warning-700)',
|
|
'--ui-error': 'var(--ui-color-error-700)',
|
|
}
|
|
|
|
const darkUiTokens = {
|
|
'--ui-bg': 'var(--color-chalk-900)',
|
|
'--ui-bg-muted': 'var(--color-chalk-950)',
|
|
'--ui-bg-elevated': 'var(--color-chalk-850)',
|
|
'--ui-bg-accented': 'var(--color-chalk-800)',
|
|
'--ui-bg-inverted': 'var(--color-chalk-50)',
|
|
'--ui-text-dimmed': 'var(--color-chalk-500)',
|
|
'--ui-text-muted': 'var(--color-chalk-400)',
|
|
'--ui-text-toned': 'var(--color-chalk-300)',
|
|
'--ui-text': 'var(--color-chalk-200)',
|
|
'--ui-text-highlighted': 'var(--color-chalk-50)',
|
|
'--ui-text-inverted': 'var(--color-chalk-900)',
|
|
'--ui-border': 'var(--color-chalk-700)',
|
|
'--ui-border-muted': 'var(--color-chalk-800)',
|
|
'--ui-border-accented': 'var(--color-chalk-500)',
|
|
'--ui-border-inverted': 'var(--color-chalk-50)',
|
|
'--ui-primary': 'var(--ui-color-primary-50)',
|
|
'--ui-secondary': 'var(--ui-color-secondary-300)',
|
|
'--ui-success': 'var(--ui-color-success-300)',
|
|
'--ui-info': 'var(--ui-color-info-300)',
|
|
'--ui-warning': 'var(--ui-color-warning-300)',
|
|
'--ui-error': 'var(--ui-color-error-300)',
|
|
}
|
|
|
|
function uiTokensOf(declarations: Map<string, string>) {
|
|
return Object.fromEntries(
|
|
[...declarations].filter(
|
|
([name]) => name.startsWith('--ui-') && !/radius|header|container/.test(name),
|
|
),
|
|
)
|
|
}
|
|
|
|
describe('main.css declares the shell design tokens', () => {
|
|
it.each(tokenRows)(
|
|
'%s resolves to the foundations value in the %s theme',
|
|
(token, theme, value) => {
|
|
const declarations = declarationsOf(theme === 'light' ? ':root' : '.dark')
|
|
expect(resolveToken(declarations, token)).toBe(value)
|
|
},
|
|
)
|
|
|
|
it('declares no --rc-canvas', () => {
|
|
expect(mainCss).not.toContain('--rc-canvas')
|
|
})
|
|
|
|
it('leaves every --ui-* token value unchanged', () => {
|
|
expect(uiTokensOf(declarationsOf(':root'))).toEqual(lightUiTokens)
|
|
expect(uiTokensOf(declarationsOf('.dark'))).toEqual(darkUiTokens)
|
|
})
|
|
})
|
|
|
|
const SELECTED_BASE = ['bg-(--rc-sand)', 'text-(--rc-on-sand)']
|
|
const SELECTED_STATES = ['hover', 'active', 'focus-visible'].flatMap((state) => [
|
|
`${state}:bg-(--rc-sand)`,
|
|
`${state}:text-(--rc-on-sand)`,
|
|
])
|
|
const FORBIDDEN_TEXT = /(^|:)(text-muted|text-white|text-inverted)$/
|
|
|
|
function navLink(root: ParentNode, href: string) {
|
|
return root.querySelector(`nav[aria-label="Main"] a[href="${href}"]`) as HTMLElement
|
|
}
|
|
|
|
function classesOf(element: Element) {
|
|
return Array.from(element.classList)
|
|
}
|
|
|
|
function expectSelected(link: HTMLElement) {
|
|
const classes = classesOf(link)
|
|
for (const cls of [...SELECTED_BASE, ...SELECTED_STATES]) {
|
|
expect(classes).toContain(cls)
|
|
}
|
|
expect(link.getAttribute('aria-current')).toBe('page')
|
|
const forbidden = classes.filter((cls) => FORBIDDEN_TEXT.test(cls))
|
|
expect(forbidden).toEqual([])
|
|
const iconClasses = classesOf(link.querySelector('svg, [data-slot="linkLeadingIcon"]')!)
|
|
expect(iconClasses.filter((cls) => FORBIDDEN_TEXT.test(cls))).toEqual([])
|
|
}
|
|
|
|
function expectUnfilled(link: HTMLElement) {
|
|
expect(link.getAttribute('aria-current')).toBeNull()
|
|
expect(classesOf(link).filter((cls) => cls.includes('--rc-sand'))).toEqual([])
|
|
}
|
|
|
|
function expectBoldParent(link: HTMLElement) {
|
|
expectUnfilled(link)
|
|
const classes = classesOf(link)
|
|
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:text-highlighted')
|
|
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:font-medium')
|
|
expect(link.closest('li')!.getAttribute('data-state')).toBe('open')
|
|
const chevron = link.querySelector('[data-slot="linkTrailingIcon"]')!
|
|
expect(classesOf(chevron)).toContain('group-data-[state=open]:rotate-90')
|
|
}
|
|
|
|
describe('DashboardShell selected nav rows keep the sand contract in every state', () => {
|
|
it('gives the selected Settings row a 2 px --ui-primary focus outline and keeps the sand fill', async () => {
|
|
const wrapper = await mountShell('/settings', { role: 'owner' })
|
|
|
|
const classes = classesOf(navLink(wrapper.element, '/settings'))
|
|
expect(classes).toContain('focus-visible:outline-2')
|
|
expect(classes).toContain('focus-visible:outline-(--ui-primary)')
|
|
expect(classes).toContain('focus-visible:bg-(--rc-sand)')
|
|
expect(classes).toContain('bg-(--rc-sand)')
|
|
})
|
|
|
|
it.each([
|
|
['/settings', '/settings'],
|
|
['/settings/media/variants', '/settings/media/variants'],
|
|
['/settings/media/cache', '/settings/media/cache'],
|
|
['/home', '/home'],
|
|
['/users', '/users'],
|
|
['/activity', '/activity'],
|
|
])(
|
|
'paints the selected row on %s with sand and never white or muted text',
|
|
async (path, href) => {
|
|
const wrapper = await mountShell(path, { role: 'owner' })
|
|
|
|
expectSelected(navLink(wrapper.element, href))
|
|
},
|
|
)
|
|
|
|
it('keeps Variants and Cache unselected under the filled Settings row on /settings', async () => {
|
|
const wrapper = await mountShell('/settings', { role: 'owner' })
|
|
|
|
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
|
|
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
|
|
})
|
|
|
|
it.each([
|
|
['/settings/media/variants', '/settings/media/cache'],
|
|
['/settings/media/cache', '/settings/media/variants'],
|
|
])('shows Settings unfilled, bold and open when %s is selected', async (path, other) => {
|
|
const wrapper = await mountShell(path, { role: 'owner' })
|
|
|
|
expectBoldParent(navLink(wrapper.element, '/settings'))
|
|
expectUnfilled(navLink(wrapper.element, other))
|
|
})
|
|
|
|
it.each([
|
|
['/settings', 'N1'],
|
|
['/settings/media/variants', 'N2'],
|
|
])('applies the same contract in the dark theme on %s (%s)', async (path) => {
|
|
document.documentElement.classList.add('dark')
|
|
try {
|
|
const wrapper = await mountShell(path, { role: 'owner' })
|
|
|
|
if (path === '/settings') {
|
|
expectSelected(navLink(wrapper.element, '/settings'))
|
|
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
|
|
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
|
|
} else {
|
|
expectSelected(navLink(wrapper.element, path))
|
|
expectBoldParent(navLink(wrapper.element, '/settings'))
|
|
}
|
|
} finally {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('DashboardShell mobile drawer rows', () => {
|
|
async function openDrawer(path: string) {
|
|
await mountShell(path, { role: 'owner', width: MOBILE_WIDTH })
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
return document.body.querySelector('[role="dialog"]') as HTMLElement
|
|
}
|
|
|
|
it('draws every drawer row 44 px tall', async () => {
|
|
const drawer = await openDrawer('/settings')
|
|
|
|
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
|
expect(links).toHaveLength(12)
|
|
for (const link of links) {
|
|
expect(classesOf(link)).toContain('max-lg:h-11')
|
|
}
|
|
})
|
|
|
|
it('lists Variants and Cache unselected under the filled Settings row on /settings', async () => {
|
|
const drawer = await openDrawer('/settings')
|
|
|
|
expect(drawer.querySelector('nav[aria-label="Main"]')!.textContent).toContain('Media')
|
|
expectSelected(navLink(drawer, '/settings'))
|
|
expectUnfilled(navLink(drawer, '/settings/media/variants'))
|
|
expectUnfilled(navLink(drawer, '/settings/media/cache'))
|
|
})
|
|
|
|
it('fills Variants and leaves Settings unfilled and bold on /settings/media/variants', async () => {
|
|
const drawer = await openDrawer('/settings/media/variants')
|
|
|
|
expectSelected(navLink(drawer, '/settings/media/variants'))
|
|
expectBoldParent(navLink(drawer, '/settings'))
|
|
expectUnfilled(navLink(drawer, '/settings/media/cache'))
|
|
})
|
|
})
|
|
|
|
function navHrefs(wrapper: VueWrapper) {
|
|
return wrapper.findAll('nav[aria-label="Main"] a').map((link) => link.attributes('href'))
|
|
}
|
|
|
|
describe('DashboardShell lists items per permission', () => {
|
|
it('shows Home, Articles, Media, Authors, Activity and Integration for a role with those grants only', async () => {
|
|
const wrapper = await mountShell('/home', {
|
|
access: customAccess(
|
|
'content.read, media.view, authors.view, activity.view=own',
|
|
'Contributor',
|
|
),
|
|
})
|
|
|
|
expect(navHrefs(wrapper)).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/media',
|
|
'/authors',
|
|
'/activity',
|
|
'/integration',
|
|
])
|
|
expect(wrapper.find('nav[aria-label="Main"]').text()).not.toContain('Roles')
|
|
})
|
|
|
|
it('shows the same items in the mobile drawer', async () => {
|
|
await mountShell('/home', {
|
|
width: MOBILE_WIDTH,
|
|
access: customAccess('content.read, media.view, authors.view, activity.view=own'),
|
|
})
|
|
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
|
|
const links = Array.from(
|
|
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
|
)
|
|
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/media',
|
|
'/authors',
|
|
'/activity',
|
|
'/integration',
|
|
])
|
|
})
|
|
|
|
it('shows the Writer built-in menu with Schema and no Users or Settings', async () => {
|
|
const wrapper = await mountShell('/home', { access: writerAccess() })
|
|
|
|
expect(navHrefs(wrapper)).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/media',
|
|
'/authors',
|
|
'/schema',
|
|
'/activity',
|
|
'/integration',
|
|
])
|
|
})
|
|
|
|
it('shows the Sites child under Settings with settings.manage_sites', async () => {
|
|
const wrapper = await mountShell('/settings', {
|
|
access: customAccess('settings.view, settings.manage_sites'),
|
|
})
|
|
|
|
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('hides the Sites child for the built-in Admin without settings.manage_sites', async () => {
|
|
const wrapper = await mountShell('/settings', {
|
|
access: customAccess('settings.view'),
|
|
})
|
|
|
|
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('lists Sites for the built-in Admin', async () => {
|
|
const wrapper = await mountShell('/settings', { role: 'admin' })
|
|
|
|
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
|
})
|
|
})
|