feat(10-04): collapsible panel with flyout, user menu with logout, breadcrumbs and dark mode

- useSidebar: collapsed below 1100px (matchMedia) or by the admin's choice,
  persisted as a boolean under summer-admin.sidebar; the viewport never
  overwrites the stored choice
- SectionPanel collapse and rail expand buttons; SectionFlyout (role menu)
  opens on hover, focus, Enter or ArrowDown on a rail item, closes on Esc or
  about 200 ms after leaving and returns focus to the rail item
- Breadcrumbs with plugin, controller and record crumbs; UserMenu (Reka
  DropdownMenu) with initials, name, role and Wyloguj
- useAuth.logout POSTs /auth/logout, clears user, navigation and settings
  and routes to login even when the call fails
- applyColorScheme toggles .dark from prefers-color-scheme (no toggle, A6)
- newest toast first, 200 ms fade and scale for dialogs and toasts
- one relation-manager type constant in the registry
- shell smoke tests; tests default to a desktop, light matchMedia
This commit is contained in:
Jakub Zych
2026-09-27 17:26:44 +02:00
parent f4e97cccad
commit 445404e394
24 changed files with 1056 additions and 77 deletions

25
admin/src/app/theme.ts Normal file
View File

@@ -0,0 +1,25 @@
// Colour scheme (D-06, flagged assumption A6): the admin follows the system
// preference only, with no toggle. The .dark class on the document element
// swaps the design's dark tokens in main.css; the sidebar stays dark in both
// modes and gains its #222b40 right border in dark mode.
export const DARK_QUERY = '(prefers-color-scheme: dark)'
/**
* Applies the current preference and follows changes. Returns the
* unsubscribe function.
*/
export function applyColorScheme(win: Window = window): () => void {
const root = win.document.documentElement
if (typeof win.matchMedia !== 'function') {
root.classList.remove('dark')
return () => undefined
}
const query = win.matchMedia(DARK_QUERY)
const apply = (dark: boolean): void => {
root.classList.toggle('dark', dark)
}
apply(query.matches)
const onChange = (event: MediaQueryListEvent): void => apply(event.matches)
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types' import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue' import FormField from './FormField.vue'
import { RELATION_MANAGER } from './registry'
// Two-column form grid (design screen 4): span left -> column 1, right -> // Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next // column 2, full (Winter's default) -> the whole row, auto and row -> the next
@@ -18,7 +19,7 @@ const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(field: FormFieldSchema): string { function spanClass(field: FormFieldSchema): string {
// The relation manager always takes the whole row (design screen 5). // The relation manager always takes the whole row (design screen 5).
if (field.type === 'relation-manager') { if (field.type === RELATION_MANAGER) {
return 'col-span-full' return 'col-span-full'
} }
switch (field.span) { switch (field.span) {

View File

@@ -33,6 +33,9 @@ export interface FieldControlProps {
recordId?: number | null recordId?: number | null
} }
/** The relation manager's field type (design screen 5). */
export const RELATION_MANAGER = 'relation-manager'
const renderers = new Map<string, Component>([ const renderers = new Map<string, Component>([
['text', TextField], ['text', TextField],
['textarea', TextareaField], ['textarea', TextareaField],
@@ -41,17 +44,17 @@ const renderers = new Map<string, Component>([
['switch', SwitchField], ['switch', SwitchField],
['checkbox', CheckboxField], ['checkbox', CheckboxField],
['relation', RelationField], ['relation', RelationField],
['relation-manager', RelationManager], [RELATION_MANAGER, RelationManager],
]) ])
/** Types whose control shows the label itself (toggle cards, relation manager). */ /** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', 'relation-manager']) const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
/** /**
* Types that need a saved record and hold no form value: never rendered on * Types that need a saved record and hold no form value: never rendered on
* create, never part of the save body (D-05, design screen 5). * create, never part of the save body (D-05, design screen 5).
*/ */
const recordBound = new Set<string>(['relation-manager']) const recordBound = new Set<string>([RELATION_MANAGER])
export function rendererFor(type: string): Component { export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField return renderers.get(type) ?? UnsupportedField

View File

@@ -1,72 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue' import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue' import SectionPanel from './SectionPanel.vue'
import Breadcrumbs from './Breadcrumbs.vue'
import UserMenu from './UserMenu.vue'
import Toast from '../ui/Toast.vue' import Toast from '../ui/Toast.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
import { activeEntry } from '../../state/useNavigation' import { activeEntry } from '../../state/useNavigation'
import { useSidebar } from '../../state/useSidebar'
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
// the plugin rail, the active plugin's section panel (hidden when the
// sidebar is collapsed by the viewport or by the admin), and the 64px header
// with breadcrumbs and the user menu. The toast queue is mounted here so
// toasts survive navigation.
const route = useRoute() const route = useRoute()
const { collapsed, watchViewport } = useSidebar()
const stopViewport = watchViewport()
onBeforeUnmount(stopViewport)
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))) const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const section = computed(() => {
const id = controllerIdFromPath(route.path)
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
})
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
)
</script> </script>
<template> <template>
<div class="flex min-h-screen bg-bg text-text"> <div class="flex min-h-screen bg-bg text-text">
<PluginRail /> <PluginRail />
<SectionPanel v-if="active" :entry="active" /> <SectionPanel v-if="active && !collapsed" :entry="active" />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8"> <header class="flex h-header shrink-0 items-center justify-between gap-4 border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2"> <Breadcrumbs />
<span v-if="route.name === 'settings' || route.name === 'settings-form'" class="font-semibold"> <UserMenu />
{{ t('backend::lang.nav.settings') }}
</span>
<template v-else-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
</template>
</template>
</nav>
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
<span
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex flex-col leading-tight">
<span class="font-semibold">{{ displayName }}</span>
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
</span>
</div>
</header> </header>
<main class="min-w-0 flex-1 px-8 py-7"> <main class="min-w-0 flex-1 px-8 py-7">
<slot /> <slot />

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { t } from '../../app/i18n'
import { recordCrumb } from '../../state/useBreadcrumbs'
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Płytarium › Albumy › Enigmatic". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons.
interface Crumb {
label: string
to?: string
}
const route = useRoute()
const crumbs = computed<Crumb[]>(() => {
if (route.name === 'settings' || route.name === 'settings-form') {
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null
if (entry) {
out.push({ label: entry.label })
}
return out
}
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
if (!plugin) {
return []
}
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
const id = controllerIdFromPath(route.path)
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
if (section) {
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })
}
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
out.push({ label: recordCrumb.value })
}
return out
})
</script>
<template>
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="min-w-0">
<ol class="flex min-w-0 items-center gap-2">
<li v-for="(crumb, index) in crumbs" :key="index" data-crumb class="flex min-w-0 items-center gap-2">
<ChevronRight v-if="index > 0" :size="14" class="shrink-0 text-muted" aria-hidden="true" />
<span
v-if="index === crumbs.length - 1 || !crumb.to"
:aria-current="index === crumbs.length - 1 ? 'page' : undefined"
:class="index === crumbs.length - 1 ? 'font-semibold text-text' : 'text-muted'"
class="truncate"
>{{ crumb.label }}</span
>
<RouterLink
v-else
:to="crumb.to"
class="truncate text-muted no-underline transition-colors duration-150 ease-out hover:text-text"
>{{ crumb.label }}</RouterLink
>
</li>
</ol>
</nav>
</template>

View File

@@ -1,13 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { Settings, Sun } from '@lucide/vue' import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
import { t } from '../../app/i18n' import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons' import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation' import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { hasSettings } from '../../state/useSettings' import { hasSettings } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import SectionFlyout from './SectionFlyout.vue'
// Plugin rail (design: Navigation shell, level 1; D-10). Clicking an item
// opens the plugin's first permitted controller; the active item owns the
// route's vendor and plugin. With the section panel collapsed, hovering or
// focusing an item, or pressing Enter or ArrowDown on it, opens the
// SectionFlyout; it closes on Esc (focus returns to the item) or about
// 200 ms after the pointer or focus leaves both the item and the flyout.
/** Delay before a flyout closes after mouse-leave or focus-out. */
const FLYOUT_CLOSE_DELAY = 200
const route = useRoute() const route = useRoute()
const { collapsed, narrow, setCollapsed } = useSidebar()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))) const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
@@ -23,6 +36,107 @@ const items = computed(() =>
current: active.value?.code === entry.code, current: active.value?.code === entry.code,
})), })),
) )
const openCode = ref<string | null>(null)
const flyout = ref<InstanceType<typeof SectionFlyout> | null>(null)
const openEntry = computed(() => railEntries.value.find((entry) => entry.code === openCode.value) ?? null)
const itemElements = new Map<string, HTMLElement>()
let closeTimer: ReturnType<typeof setTimeout> | null = null
let returningFocus = false
function flyoutId(code: string): string {
return `section-flyout-${code}`
}
function setItemElement(code: string, instance: unknown): void {
const element = (instance as { $el?: unknown } | null)?.$el
if (element instanceof HTMLElement) {
itemElements.set(code, element)
} else {
itemElements.delete(code)
}
}
function cancelClose(): void {
if (closeTimer !== null) {
clearTimeout(closeTimer)
closeTimer = null
}
}
function closeFlyout(): void {
cancelClose()
openCode.value = null
}
function scheduleClose(): void {
cancelClose()
closeTimer = setTimeout(() => {
closeTimer = null
const focused = document.activeElement
const code = openCode.value
// Keep it open while focus sits on its rail item or inside it.
if (code !== null && (itemElements.get(code) === focused || flyout.value?.contains(focused))) {
return
}
openCode.value = null
}, FLYOUT_CLOSE_DELAY)
}
function openFlyout(code: string): void {
if (!collapsed.value) {
return
}
cancelClose()
openCode.value = code
}
function onItemFocus(code: string): void {
if (!returningFocus) {
openFlyout(code)
}
}
async function onItemKeydown(event: KeyboardEvent, code: string): Promise<void> {
if (!collapsed.value) {
return
}
if (event.key === 'Enter' || event.key === 'ArrowDown') {
event.preventDefault()
openFlyout(code)
await nextTick()
await flyout.value?.focusFirst()
} else if (event.key === 'Escape' && openCode.value !== null) {
event.preventDefault()
closeFlyout()
}
}
/** Esc inside the flyout: close it and return focus to its rail item. */
function onFlyoutEscape(): void {
const code = openCode.value
closeFlyout()
if (code === null) {
return
}
returningFocus = true
itemElements.get(code)?.focus()
returningFocus = false
}
function expand(): void {
setCollapsed(false)
void nextTick(() => document.querySelector<HTMLElement>('[data-sidebar-collapse]')?.focus())
}
// Expanding the panel (manually or by widening the window) drops the flyout.
watch(collapsed, (value) => {
if (!value) {
closeFlyout()
}
})
onBeforeUnmount(cancelClose)
</script> </script>
<template> <template>
@@ -36,22 +150,56 @@ const items = computed(() =>
<RouterLink <RouterLink
v-for="item in items" v-for="item in items"
:key="item.entry.code" :key="item.entry.code"
:ref="(instance) => setItemElement(item.entry.code, instance)"
:to="item.to" :to="item.to"
:data-rail-item="item.entry.code"
:aria-current="item.current ? 'page' : undefined" :aria-current="item.current ? 'page' : undefined"
:aria-haspopup="collapsed ? 'menu' : undefined"
:aria-expanded="collapsed ? (openCode === item.entry.code ? 'true' : 'false') : undefined"
:aria-controls="collapsed && openCode === item.entry.code ? flyoutId(item.entry.code) : undefined"
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'" :class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline" class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
@mouseenter="openFlyout(item.entry.code)"
@mouseleave="scheduleClose"
@focus="onItemFocus(item.entry.code)"
@blur="scheduleClose"
@keydown="onItemKeydown($event, item.entry.code)"
> >
<component :is="item.icon" :size="20" aria-hidden="true" /> <component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span> <span>{{ item.entry.label }}</span>
</RouterLink> </RouterLink>
<SectionFlyout
v-if="collapsed && openEntry"
:id="flyoutId(openEntry.code)"
ref="flyout"
:entry="openEntry"
@mouseenter="cancelClose"
@mouseleave="scheduleClose"
@focusin="cancelClose"
@focusout="scheduleClose"
@escape="onFlyoutEscape"
@leave="scheduleClose"
@navigate="closeFlyout"
/>
<span class="flex-1" aria-hidden="true" /> <span class="flex-1" aria-hidden="true" />
<button
v-if="collapsed && !narrow"
type="button"
data-sidebar-expand
:aria-label="t('backend::lang.nav.expand')"
:title="t('backend::lang.nav.expand')"
class="mb-1 flex size-10 items-center justify-center rounded-inner text-side-text transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
@click="expand"
>
<PanelLeftOpen :size="20" aria-hidden="true" />
</button>
<RouterLink <RouterLink
v-if="hasSettings" v-if="hasSettings"
to="/settings" to="/settings"
data-rail-settings data-rail-settings
:aria-current="inSettings ? 'page' : undefined" :aria-current="inSettings ? 'page' : undefined"
:class="inSettings ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'" :class="inSettings ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline" class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
> >
<Settings :size="20" aria-hidden="true" /> <Settings :size="20" aria-hidden="true" />
<span>{{ t('backend::lang.nav.settings') }}</span> <span>{{ t('backend::lang.nav.settings') }}</span>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
// Collapsed-rail flyout (design: Navigation shell, Collapsed / tablet; D-10):
// the plugin's side menu in a 230px menu 8px right of the rail, aligned with
// the header area. role="menu" with menuitems; arrow keys move between items,
// Esc asks the rail to close it and return focus to the rail item.
const props = defineProps<{ entry: NavigationEntry; id: string }>()
const emit = defineEmits<{ escape: []; leave: []; navigate: [] }>()
const route = useRoute()
const root = ref<HTMLElement | null>(null)
const currentController = computed(() => controllerIdFromPath(route.path))
const items = computed(() =>
props.entry.sideMenu.flatMap((item) => {
const to = controllerPath(item.controller)
if (!to) {
return []
}
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
function menuItems(): HTMLElement[] {
return [...(root.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]
}
/** Moves focus to the first item (Enter or ArrowDown on the rail item). */
async function focusFirst(): Promise<void> {
await nextTick()
menuItems()[0]?.focus()
}
function contains(element: Element | null): boolean {
return !!element && !!root.value?.contains(element)
}
function onKeydown(event: KeyboardEvent): void {
const list = menuItems()
const index = list.indexOf(document.activeElement as HTMLElement)
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
list[(index + 1) % list.length]?.focus()
break
case 'ArrowUp':
event.preventDefault()
list[(index - 1 + list.length) % list.length]?.focus()
break
case 'Home':
event.preventDefault()
list[0]?.focus()
break
case 'End':
event.preventDefault()
list[list.length - 1]?.focus()
break
case 'Escape':
event.preventDefault()
emit('escape')
break
case 'Tab':
emit('leave')
break
}
}
defineExpose({ focusFirst, contains })
</script>
<template>
<div
:id="id"
ref="root"
role="menu"
data-section-flyout
:aria-label="entry.label"
class="fixed top-3 left-[calc(var(--spacing-rail)+8px)] z-40 flex w-[230px] flex-col gap-0.5 rounded-[14px] border border-border bg-surface p-2 text-text shadow-pop"
@keydown="onKeydown"
>
<span class="px-2.5 pt-1 pb-2 text-[14px] font-bold" aria-hidden="true">{{ entry.label }}</span>
<RouterLink
v-for="link in items"
:key="link.item.code"
:to="link.to"
role="menuitem"
tabindex="-1"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-[38px] items-center gap-3 rounded-tab px-2.5 no-underline transition-colors duration-150 ease-out"
@click="emit('navigate')"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>
</RouterLink>
</div>
</template>

View File

@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, nextTick } from 'vue'
import { PanelLeftClose } from '@lucide/vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types' import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes' import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons' import { iconFor } from '../../app/icons'
import { t } from '../../app/i18n' import { t } from '../../app/i18n'
import { setCollapsed } from '../../state/useSidebar'
// Section panel (design: Navigation shell, level 2): the active plugin's
// permitted side menu, 224px, with the plugin label and a "Zwiń menu" button
// in its header. Collapsing hides it and leaves only the rail (D-06).
const props = defineProps<{ entry: NavigationEntry }>() const props = defineProps<{ entry: NavigationEntry }>()
const route = useRoute() const route = useRoute()
@@ -20,6 +26,11 @@ const items = computed(() =>
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }] return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}), }),
) )
function collapse(): void {
setCollapsed(true)
void nextTick(() => document.querySelector<HTMLElement>('[data-sidebar-expand]')?.focus())
}
</script> </script>
<template> <template>
@@ -27,8 +38,18 @@ const items = computed(() =>
:aria-label="t('backend::lang.nav.sections')" :aria-label="t('backend::lang.nav.sections')"
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5" class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
> >
<div class="mb-3.5 flex h-10 items-center px-2"> <div class="mb-3.5 flex h-10 items-center justify-between gap-2 pl-2">
<span class="text-[16px] font-bold">{{ entry.label }}</span> <span class="truncate text-[16px] font-bold">{{ entry.label }}</span>
<button
type="button"
data-sidebar-collapse
:aria-label="t('backend::lang.nav.collapse')"
:title="t('backend::lang.nav.collapse')"
class="flex size-[30px] shrink-0 items-center justify-center rounded-tab text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="collapse"
>
<PanelLeftClose :size="18" aria-hidden="true" />
</button>
</div> </div>
<RouterLink <RouterLink
v-for="link in items" v-for="link in items"
@@ -36,7 +57,7 @@ const items = computed(() =>
:to="link.to" :to="link.to"
:aria-current="link.current ? 'page' : undefined" :aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'" :class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline" class="flex h-nav items-center gap-3 rounded-control px-3 no-underline transition-colors duration-150 ease-out"
> >
<component :is="link.icon" :size="18" aria-hidden="true" /> <component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span> <span>{{ link.item.label }}</span>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import { ChevronDown, LogOut } from '@lucide/vue'
import { t } from '../../app/i18n'
import { currentUser, logout } from '../../state/useAuth'
// User menu (design: Top header): a 44px button with the initials avatar,
// the name above the role from /auth/me (both hidden at tablet width) and a
// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and
// e-mail and a Wyloguj item. Logging out always ends on the login screen,
// even when the logout call fails (T-10-23).
const router = useRouter()
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
/** First and last name initials, else the login's first letters. */
const initials = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
if (parts.length > 0) {
return parts
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
return user.login.slice(0, 2).toUpperCase()
})
const roleName = computed(() => currentUser.value?.role?.name ?? '')
async function onLogout(): Promise<void> {
await logout(router)
}
</script>
<template>
<!-- The menu stays mounted when the user clears (logout); the shell itself
unmounts on the login route. -->
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger
data-user-menu
:disabled="!currentUser"
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
>
<span
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
data-user-initials
>{{ initials }}</span
>
<span class="flex flex-col leading-tight max-[1099px]:hidden" data-user-identity>
<span class="font-semibold">{{ displayName }}</span>
<span v-if="roleName" class="text-[12px] text-muted" data-user-role>{{ roleName }}</span>
</span>
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
data-user-menu-content
align="end"
:side-offset="8"
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
>
<div class="flex items-center gap-3 px-2 py-2">
<span
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-bold">{{ displayName }}</span>
<span class="truncate text-[13px] text-muted">{{ currentUser?.email }}</span>
</span>
</div>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem
data-action="logout"
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
@select="onLogout"
>
<LogOut :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.auth.logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -2,11 +2,20 @@
import { Check, CircleAlert, X } from '@lucide/vue' import { Check, CircleAlert, X } from '@lucide/vue'
import { t } from '../../app/i18n' import { t } from '../../app/i18n'
import { dismissToast, toasts } from '../../state/useToasts' import { dismissToast, toasts } from '../../state/useToasts'
// Toast stack (design: Interactions, Save and Relation manager): one global
// queue, most recent first, success as status and danger as alert. Toasts
// fade and scale in over 200 ms (no decorative animation) and dismiss after
// about 4 s or on the close button.
</script> </script>
<template> <template>
<!-- Bottom centre, 88px up so it clears the sticky form footer. --> <!-- Bottom centre, 88px up so it clears the sticky form footer. -->
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4"> <TransitionGroup
tag="div"
name="toast"
class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4"
>
<div <div
v-for="toast in toasts" v-for="toast in toasts"
:key="toast.id" :key="toast.id"
@@ -26,11 +35,11 @@ import { dismissToast, toasts } from '../../state/useToasts'
<button <button
type="button" type="button"
:aria-label="t('backend::lang.form.close')" :aria-label="t('backend::lang.form.close')"
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white" class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
@click="dismissToast(toast.id)" @click="dismissToast(toast.id)"
> >
<X :size="16" aria-hidden="true" /> <X :size="16" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </TransitionGroup>
</template> </template>

View File

@@ -3,12 +3,15 @@ import App from './App.vue'
import { createAdminRouter } from './app/router' import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client' import { onUnauthorized } from './api/client'
import { loadStrings } from './app/i18n' import { loadStrings } from './app/i18n'
import { applyColorScheme } from './app/theme'
import { clearUser, me } from './state/useAuth' import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation' import { loadNavigation } from './state/useNavigation'
import { loadSettings } from './state/useSettings' import { loadSettings } from './state/useSettings'
import './styles/main.css' import './styles/main.css'
async function boot(): Promise<void> { async function boot(): Promise<void> {
// Dark mode follows the system preference before anything renders (A6).
applyColorScheme()
// Boot order (research Pattern 7): the public strings bundle first, so the // Boot order (research Pattern 7): the public strings bundle first, so the
// login screen is translated, then the session, then navigation. // login screen is translated, then the session, then navigation.
await loadStrings().catch(() => undefined) await loadStrings().catch(() => undefined)

View File

@@ -1,8 +1,11 @@
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA // Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
// keeps only the profile and the access lifetime, never a token. // keeps only the profile and the access lifetime, never a token.
import { readonly, ref } from 'vue' import { readonly, ref } from 'vue'
import type { Router } from 'vue-router'
import { api, onRefreshed, refreshSession } from '../api/client' import { api, onRefreshed, refreshSession } from '../api/client'
import type { AdminProfile } from '../api/types' import type { AdminProfile } from '../api/types'
import { setNavigation } from './useNavigation'
import { setSettings } from './useSettings'
const user = ref<AdminProfile | null>(null) const user = ref<AdminProfile | null>(null)
const expiresIn = ref<number | null>(null) const expiresIn = ref<number | null>(null)
@@ -57,11 +60,33 @@ export function clearUser(): void {
expiresIn.value = null expiresIn.value = null
} }
/**
* Ends the session: POST /auth/logout (the server blacklists the token and
* expires the HttpOnly cookie), then clears the in-memory user, navigation
* and settings and routes to the login screen. The local state is cleared
* and the login screen shown even when the call fails (T-10-23).
*/
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
try {
await api.POST('/auth/logout')
} catch {
// Network failure: the session still ends on this device.
} finally {
clearUser()
setNavigation([])
setSettings([])
}
if (router) {
await router.replace({ name: 'login' }).catch(() => undefined)
}
}
export function useAuth() { export function useAuth() {
return { return {
user: currentUser, user: currentUser,
expiresIn: readonly(expiresIn), expiresIn: readonly(expiresIn),
login, login,
logout,
me, me,
clearUser, clearUser,
} }

View File

@@ -0,0 +1,16 @@
// The record crumb of the header breadcrumbs (design: Top header). The form
// view owns the record's title; it publishes it here while it is mounted so
// the shell can show "Plugin › Controller › Record".
import { readonly, ref } from 'vue'
const record = ref('')
export const recordCrumb = readonly(record)
export function setRecordCrumb(label: string): void {
record.value = label
}
export function clearRecordCrumb(): void {
record.value = ''
}

View File

@@ -0,0 +1,71 @@
// Sidebar collapsed state (design: Navigation shell, Collapsed / tablet;
// D-06, D-10). The section panel hides below about 1100px or when the admin
// collapses it. The viewport only forces the collapse and never overwrites
// the admin's own choice, which persists in localStorage as a boolean under
// SIDEBAR_KEY. Nothing else is ever written to browser storage (T-10-22).
import { computed, readonly, ref } from 'vue'
export const SIDEBAR_KEY = 'summer-admin.sidebar'
export const NARROW_QUERY = '(max-width: 1099px)'
const manual = ref(false)
const narrow = ref(false)
/** The panel is hidden: forced by the viewport or chosen by the admin. */
export const sidebarCollapsed = computed(() => narrow.value || manual.value)
function storage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
function readPreference(): boolean {
try {
return storage()?.getItem(SIDEBAR_KEY) === 'true'
} catch {
return false
}
}
/** Records the admin's choice; only 'true' or 'false' is stored. */
export function setCollapsed(value: boolean): void {
manual.value = value
try {
storage()?.setItem(SIDEBAR_KEY, value ? 'true' : 'false')
} catch {
// Storage may be full or disabled; the choice still holds for the session.
}
}
/**
* Reads the stored preference and follows the viewport breakpoint. Returns
* the unsubscribe function; the shell calls it when it unmounts.
*/
export function watchViewport(): () => void {
manual.value = readPreference()
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
narrow.value = false
return () => undefined
}
const query = window.matchMedia(NARROW_QUERY)
narrow.value = query.matches
const onChange = (event: MediaQueryListEvent): void => {
narrow.value = event.matches
}
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}
export function useSidebar() {
return {
collapsed: sidebarCollapsed,
/** The viewport is below the breakpoint; expanding is not possible. */
narrow: readonly(narrow),
manual: readonly(manual),
setCollapsed,
watchViewport,
}
}

View File

@@ -1,6 +1,7 @@
// Global toast queue (design: State Management). Toasts outlive route // Global toast queue (design: State Management). Toasts outlive route
// changes because AppShell mounts the renderer, so "Zapisz i zamknij" can // changes because AppShell mounts the renderer, so "Zapisz i zamknij" can
// show its toast on the list it returns to. // show its toast on the list it returns to. One queue; the most recent toast
// comes first.
import { readonly, ref } from 'vue' import { readonly, ref } from 'vue'
export type ToastTone = 'success' | 'danger' export type ToastTone = 'success' | 'danger'
@@ -22,7 +23,7 @@ export const toasts = readonly(items)
export function showToast(text: string, tone: ToastTone = 'success'): number { export function showToast(text: string, tone: ToastTone = 'success'): number {
const id = nextId++ const id = nextId++
items.value = [...items.value, { id, text, tone }] items.value = [{ id, text, tone }, ...items.value]
timers.set( timers.set(
id, id,
setTimeout(() => dismissToast(id), TOAST_TIMEOUT), setTimeout(() => dismissToast(id), TOAST_TIMEOUT),

View File

@@ -104,6 +104,7 @@
} }
.dark { .dark {
color-scheme: dark;
--c-bg: #111726; --c-bg: #111726;
--c-surface: #182033; --c-surface: #182033;
--c-subtle: #1f283d; --c-subtle: #1f283d;
@@ -164,3 +165,56 @@
color: var(--c-placeholder); color: var(--c-placeholder);
} }
} }
/* Motion (design: Interactions, Transitions): subtle only. Hover and
background changes use the 150 ms ease-out utilities on each control;
dialogs and their backdrop fade in and scale from 0.98 over 200 ms; toasts
fade in the same way. Closing is immediate, so Reka's presence never waits
on an exit animation. No decorative animation. */
@keyframes summer-fade-in {
from {
opacity: 0;
}
}
@keyframes summer-dialog-in {
from {
opacity: 0;
scale: 0.98;
}
}
@layer components {
/* Backdrops of the picker (data-dialog-overlay) and of ConfirmDialog. */
.bg-overlay[data-state="open"] {
animation: summer-fade-in 200ms ease-out;
}
[data-dialog-content][data-state="open"],
[role="alertdialog"][data-state="open"] {
animation: summer-dialog-in 200ms ease-out;
}
.toast-enter-active {
transition:
opacity 200ms ease-out,
scale 200ms ease-out;
}
.toast-enter-from {
opacity: 0;
scale: 0.98;
}
}
@media (prefers-reduced-motion: reduce) {
.bg-overlay,
[data-dialog-content],
[role="alertdialog"] {
animation: none !important;
}
.toast-enter-active {
transition: none;
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue' import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client' import { api } from '../api/client'
@@ -28,6 +28,7 @@ import {
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue' import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm' import { useConfirm } from '../components/ui/confirm'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { showToast } from '../state/useToasts' import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen // Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
@@ -299,6 +300,10 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload)) onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload)) onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
// The header's last breadcrumb is this record's title (design: Top header).
watchEffect(() => setRecordCrumb(loading.value ? '' : title.value))
onBeforeUnmount(clearRecordCrumb)
void load() void load()
</script> </script>

View File

@@ -4,3 +4,18 @@ const meta = document.createElement('meta')
meta.setAttribute('name', 'summer-admin-base') meta.setAttribute('name', 'summer-admin-base')
meta.setAttribute('content', '/admin-test') meta.setAttribute('content', '/admin-test')
document.head.appendChild(meta) document.head.appendChild(meta)
// happy-dom's default viewport is 1024px wide, below the 1100px sidebar
// breakpoint. Tests get a desktop, light-mode media environment; the shell
// smoke test spies on matchMedia to simulate narrow or dark screens.
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as MediaQueryList

View File

@@ -0,0 +1,339 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
import { clearUser, currentUser } from '../../src/state/useAuth'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { NARROW_QUERY, SIDEBAR_KEY } from '../../src/state/useSidebar'
import { clearToasts, showToast } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import { API, mountApp, requestsTo, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: listSchema },
[`GET ${BASE}`]: { body: listRows },
[`GET ${BASE}/schema/form`]: { body: formSchema },
[`GET ${BASE}/1`]: { body: record },
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
...overrides,
}
}
type Listener = (event: MediaQueryListEvent) => void
/** A matchMedia whose answers the test controls, with change events. */
function fakeMedia(initial: Record<string, boolean>) {
const state = { ...initial }
const listeners = new Map<string, Set<Listener>>()
const of = (query: string): Set<Listener> => {
let set = listeners.get(query)
if (!set) {
set = new Set()
listeners.set(query, set)
}
return set
}
const impl = (query: string): MediaQueryList =>
({
get matches() {
return state[query] ?? false
},
media: query,
onchange: null,
addEventListener: (_type: string, listener: Listener) => of(query).add(listener),
removeEventListener: (_type: string, listener: Listener) => of(query).delete(listener),
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as unknown as MediaQueryList
vi.spyOn(window, 'matchMedia').mockImplementation(impl)
return {
change(query: string, matches: boolean): void {
state[query] = matches
for (const listener of of(query)) {
listener({ matches, media: query } as MediaQueryListEvent)
}
},
listeners: (query: string) => of(query).size,
}
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function panel(wrapper: VueWrapper) {
return wrapper.find('aside')
}
function railItem(wrapper: VueWrapper, code = 'demo'): HTMLElement {
return wrapper.find(`[data-rail-item="${code}"]`).element as HTMLElement
}
function flyout(): HTMLElement | null {
return document.body.querySelector('[data-section-flyout]')
}
function key(target: Element, name: string): void {
target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }))
}
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
window.localStorage.clear()
})
afterEach(() => {
document.body.innerHTML = ''
document.documentElement.classList.remove('dark')
window.localStorage.clear()
})
// Registered after the cleanup above, so it runs first (hooks run as a
// stack): the app unmounts, with its timers and portals, before the body is
// cleared.
enableAutoUnmount(afterEach)
describe('collapsible section panel', () => {
it('persists the manual choice as a boolean under summer-admin.sidebar and restores it', async () => {
const first = await mountApp('/acme/demo/widgets', routes(), { attach: true })
expect(panel(first.wrapper).exists()).toBe(true)
expect(panel(first.wrapper).text()).toContain('Demo')
expect(first.wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
await first.wrapper.find('[data-sidebar-collapse]').trigger('click')
await flushPromises()
expect(panel(first.wrapper).exists()).toBe(false)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
expect(document.activeElement?.hasAttribute('data-sidebar-expand')).toBe(true)
first.wrapper.unmount()
const second = await mountApp('/acme/demo/widgets', routes())
expect(panel(second.wrapper).exists()).toBe(false)
const expand = second.wrapper.find('[data-sidebar-expand]')
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
await expand.trigger('click')
expect(panel(second.wrapper).exists()).toBe(true)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
// The sidebar preference is the only thing in browser storage.
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
})
it('collapses below 1100px without overwriting the stored preference', async () => {
window.localStorage.setItem(SIDEBAR_KEY, 'false')
const media = fakeMedia({ [NARROW_QUERY]: true })
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(panel(wrapper).exists()).toBe(false)
// Expanding is impossible at this width, so no expand button.
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
media.change(NARROW_QUERY, false)
await flushPromises()
expect(panel(wrapper).exists()).toBe(true)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
wrapper.unmount()
expect(media.listeners(NARROW_QUERY)).toBe(0)
})
})
describe('section flyout', () => {
it('opens from a focused rail item with Enter, moves by arrows, and Esc returns focus', async () => {
window.localStorage.setItem(SIDEBAR_KEY, 'true')
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
const item = railItem(wrapper)
expect(item.getAttribute('aria-haspopup')).toBe('menu')
expect(item.getAttribute('aria-expanded')).toBe('false')
item.focus()
await flushPromises()
expect(flyout()).not.toBeNull()
key(item, 'Enter')
await flushPromises()
const menu = flyout()!
expect(menu.getAttribute('role')).toBe('menu')
expect(menu.getAttribute('aria-label')).toBe('Demo')
expect(item.getAttribute('aria-expanded')).toBe('true')
expect(item.getAttribute('aria-controls')).toBe(menu.id)
const items = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(items.map((element) => element.textContent?.trim())).toEqual(['Widgets', 'Gadgets'])
expect(items[0]!.getAttribute('aria-current')).toBe('page')
expect(document.activeElement).toBe(items[0])
key(items[0]!, 'ArrowDown')
expect(document.activeElement).toBe(items[1])
key(items[1]!, 'Escape')
await flushPromises()
expect(flyout()).toBeNull()
expect(document.activeElement).toBe(item)
// Returning focus does not reopen it.
await wait(250)
expect(flyout()).toBeNull()
})
it('opens with ArrowDown and on hover, and closes about 200 ms after mouse-leave', async () => {
window.localStorage.setItem(SIDEBAR_KEY, 'true')
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
const tools = railItem(wrapper, 'tools')
tools.focus()
key(tools, 'ArrowDown')
await flushPromises()
expect(flyout()!.getAttribute('aria-label')).toBe('Tools')
expect(document.activeElement?.textContent?.trim()).toBe('Hammers')
key(document.activeElement!, 'Escape')
await flushPromises()
const item = wrapper.find('[data-rail-item="demo"]')
await item.trigger('mouseenter')
expect(flyout()!.getAttribute('aria-label')).toBe('Demo')
await item.trigger('mouseleave')
await wait(100)
expect(flyout()).not.toBeNull()
// Moving onto the flyout keeps it open.
flyout()!.dispatchEvent(new MouseEvent('mouseenter'))
await wait(250)
expect(flyout()).not.toBeNull()
flyout()!.dispatchEvent(new MouseEvent('mouseleave'))
await wait(250)
await flushPromises()
expect(flyout()).toBeNull()
})
it('never opens while the panel is expanded', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
const item = railItem(wrapper)
item.focus()
key(item, 'Enter')
await wrapper.find('[data-rail-item="demo"]').trigger('mouseenter')
await flushPromises()
expect(flyout()).toBeNull()
expect(item.hasAttribute('aria-haspopup')).toBe(false)
})
})
describe('header', () => {
it('shows plugin, controller and record crumbs on a record route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
const crumbs = wrapper.findAll('header [data-crumb]')
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets', 'Blue widget'])
expect(crumbs[0]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
expect(crumbs[1]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
expect(crumbs[2]!.find('a').exists()).toBe(false)
expect(crumbs[2]!.find('[aria-current="page"]').text()).toBe('Blue widget')
expect(wrapper.findAll('header [data-crumb] svg')).toHaveLength(2)
})
it('ends on the controller crumb on a list route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
const crumbs = wrapper.findAll('header [data-crumb]')
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets'])
expect(crumbs[1]!.find('[aria-current="page"]').text()).toBe('Widgets')
})
it('shows initials, name and role, and the menu with the e-mail and Wyloguj', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
const trigger = wrapper.find('[data-user-menu]')
expect(trigger.find('[data-user-initials]').text()).toBe('DD')
expect(trigger.find('[data-user-identity]').text()).toContain('Dana Dev')
expect(trigger.find('[data-user-role]').text()).toBe('Developer')
expect(trigger.find('[data-user-identity]').classes()).toContain('max-[1099px]:hidden')
await trigger.trigger('keydown', { key: 'Enter' })
await flushPromises()
const menu = document.body.querySelector('[data-user-menu-content]')!
expect(menu.getAttribute('role')).toBe('menu')
expect(menu.textContent).toContain('dev@example.test')
expect(menu.querySelector('[data-action="logout"]')!.getAttribute('role')).toBe('menuitem')
expect(menu.querySelector('[data-action="logout"]')!.textContent?.trim()).toBe('Wyloguj')
})
it.each([
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
['fails', { status: 500, body: { error: { code: 'server_error', message: 'Boom', details: {} } } }],
])('logs out when the call %s: POST /auth/logout, state cleared, login screen', async (_name, reply) => {
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`POST ${API}/auth/logout`]: reply }), {
attach: true,
})
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
await flushPromises()
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
await flushPromises()
const [post] = requestsTo(calls, 'POST', `${API}/auth/logout`)
expect(post).toBeDefined()
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(currentUser.value).toBeNull()
expect(navigation.value).toEqual([])
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.find('aside').exists()).toBe(false)
})
it('logs out to the login screen even when the network fails', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets', routes({
[`POST ${API}/auth/logout`]: () => {
throw new TypeError('network down')
},
}), { attach: true })
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
await flushPromises()
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
await flushPromises()
expect(currentUser.value).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
})
describe('theme and toasts', () => {
it('toggles the dark class from prefers-color-scheme and follows changes', () => {
const media = fakeMedia({ [DARK_QUERY]: true })
const stop = applyColorScheme()
expect(document.documentElement.classList.contains('dark')).toBe(true)
media.change(DARK_QUERY, false)
expect(document.documentElement.classList.contains('dark')).toBe(false)
media.change(DARK_QUERY, true)
expect(document.documentElement.classList.contains('dark')).toBe(true)
stop()
expect(media.listeners(DARK_QUERY)).toBe(0)
})
it('stays light when the system prefers light', () => {
fakeMedia({ [DARK_QUERY]: false })
applyColorScheme()()
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('shows the most recent toast first', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
showToast('First')
showToast('Second', 'danger')
await flushPromises()
const texts = wrapper.findAll('[data-tone]').map((toast) => toast.text())
expect(texts[0]).toContain('Second')
expect(texts[1]).toContain('First')
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BC0qEqbI.js"></script> <script type="module" crossorigin src="./assets/index-BQShvn7O.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-wkQq4mI5.css"> <link rel="stylesheet" crossorigin href="./assets/index-BBZKA2Yr.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>