Files
summercms/admin/tests/shell/PluginRail.test.ts
Jakub Zych f0ab26b93f test(quick-261016-0fm): theme switch, language switcher and footer offset
- theme: light and dark ignore prefers-color-scheme, choices and guard
- i18n: ?locale= only with a preference, availableLocales from meta
- usePreferences: cache validation, cycle order and PUT bodies, revert
  with a danger toast, profile apply, setLocale save, cache and reload
- rail theme button, user menu language group, --shell-offset and the
  form save bar offset; fixture strings and resetState theme reset
2026-10-16 11:48:39 +02:00

186 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { themeChoice } from '../../src/state/usePreferences'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import PluginRail from '../../src/components/shell/PluginRail.vue'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, watchViewport } from '../../src/state/useSidebar'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { API, fakeMedia, keydown, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
async function mountRail(path: string) {
await signIn()
setNavigation(navigationFixture.data)
const router = await routerAt(path)
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return { wrapper, router }
}
let stopViewport: (() => void) | null = null
beforeEach(() => {
resetState()
setCollapsed(false)
stopViewport = null
})
afterEach(() => {
stopViewport?.()
setCollapsed(false)
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('plugin rail', () => {
it('is a labelled navigation of the permitted plugins in order, the active one aria-current', async () => {
const { wrapper } = await mountRail('/acme/demo/gadgets')
const nav = wrapper.find('nav')
expect(nav.attributes('aria-label')).toBe('Wtyczki')
const items = wrapper.findAll('[data-rail-item]')
expect(items.map((item) => item.attributes('data-rail-item'))).toEqual(['demo', 'tools'])
expect(items.map((item) => item.text())).toEqual(['Demo', 'Tools'])
expect(items[0]!.attributes('aria-current')).toBe('page')
expect(items[1]!.attributes('aria-current')).toBeUndefined()
expect(items[0]!.attributes('href')).toBe('/acme/demo/widgets')
expect(items[1]!.attributes('href')).toBe('/acme/tools/hammers')
// Expanded: no popup semantics on the items.
expect(items[0]!.attributes('aria-haspopup')).toBeUndefined()
expect(items[0]!.attributes('aria-expanded')).toBeUndefined()
})
it('shows Ustawienia only with a permitted settings page, current on settings routes', async () => {
const { wrapper } = await mountRail('/settings/mail')
expect(wrapper.find('[data-rail-settings]').exists()).toBe(false)
setSettings(settingsFixture.list.data)
await flushPromises()
const link = wrapper.find('[data-rail-settings]')
expect(link.text()).toBe('Ustawienia')
expect(link.attributes('aria-current')).toBe('page')
})
it('marks Ustawienia current on a settings-linked controller without a navigation entry', async () => {
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
const widgets = { ...locales, code: 'widgets', label: 'Widgets', controller: 'acme.demo.widgets' }
setSettings([...settingsFixture.list.data, locales, widgets])
const { wrapper } = await mountRail('/acme/lang/locales')
expect(wrapper.find('[data-rail-settings]').attributes('aria-current')).toBe('page')
expect(wrapper.findAll('[data-rail-item][aria-current="page"]')).toHaveLength(0)
// A linked controller that also has a navigation entry keeps its plugin.
const nav = await mountRail('/acme/demo/widgets')
expect(nav.wrapper.find('[data-rail-settings]').attributes('aria-current')).toBeUndefined()
expect(nav.wrapper.find('[data-rail-item="demo"]').attributes('aria-current')).toBe('page')
})
it('offers menu semantics and the expand button after a manual collapse', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="demo"]')
expect(item.attributes('aria-haspopup')).toBe('menu')
expect(item.attributes('aria-expanded')).toBe('false')
const expand = wrapper.find('[data-sidebar-expand]')
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
await expand.trigger('click')
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
})
it('has no expand button when the viewport forces the collapse', async () => {
fakeMedia({ [NARROW_QUERY]: true })
stopViewport = watchViewport()
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-rail-item="demo"]').attributes('aria-haspopup')).toBe('menu')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
})
it('opens the flyout on hover, links it by aria-controls and closes it on expand', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="tools"]')
await item.trigger('mouseenter')
const flyout = document.querySelector('[data-section-flyout]')
expect(flyout?.getAttribute('aria-label')).toBe('Tools')
expect(item.attributes('aria-expanded')).toBe('true')
expect(item.attributes('aria-controls')).toBe(flyout?.id)
setCollapsed(false)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
})
it('ignores keys while expanded and closes an open flyout on Escape', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
const item = wrapper.find('[data-rail-item="demo"]').element
expect(keydown(item, 'Enter').defaultPrevented).toBe(false)
setCollapsed(true)
await flushPromises()
expect(keydown(item, 'ArrowDown').defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
expect(keydown(item, 'Escape').defaultPrevented).toBe(true)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
expect(keydown(item, 'Escape').defaultPrevented).toBe(false)
})
it('keeps the flyout open while focus stays on its rail item', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="demo"]')
;(item.element as HTMLElement).focus()
await item.trigger('focus')
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
await item.trigger('mouseleave')
await wait(250)
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
;(item.element as HTMLElement).blur()
await item.trigger('blur')
await wait(250)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
})
it('cycles the theme from the top button, naming the current choice, and keeps Ustawienia', async () => {
const calls = await signIn({
[`PUT ${API}/account/preferences`]: { body: { data: { theme: 'light', locale: '' }, meta: {} } },
})
setNavigation(navigationFixture.data)
setSettings(settingsFixture.list.data)
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
const button = wrapper.get('[data-theme-toggle]')
expect(button.element.tagName).toBe('BUTTON')
expect(button.attributes('type')).toBe('button')
expect(button.attributes('aria-label')).toBe('Motyw: Systemowy')
expect(button.attributes('title')).toBe('Motyw: Systemowy')
await button.trigger('click')
await flushPromises()
expect(themeChoice.value).toBe('light')
expect(button.attributes('aria-label')).toBe('Motyw: Jasny')
await button.trigger('click')
await flushPromises()
expect(button.attributes('aria-label')).toBe('Motyw: Ciemny')
expect(document.documentElement.classList.contains('dark')).toBe(true)
const puts = requestsTo(calls, 'PUT', `${API}/account/preferences`)
expect(await Promise.all(puts.map(async (request) => JSON.parse(await request.clone().text())))).toEqual([
{ theme: 'light' },
{ theme: 'dark' },
])
expect(wrapper.find('[data-rail-settings]').exists()).toBe(true)
document.documentElement.classList.remove('dark')
})
})