Files
summercms/admin/src/components/shell/SectionFlyout.vue
Jakub Zych 445404e394 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
2026-09-27 17:26:44 +02:00

104 lines
3.3 KiB
Vue

<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>