- 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
104 lines
3.3 KiB
Vue
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>
|