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:
103
admin/src/components/shell/SectionFlyout.vue
Normal file
103
admin/src/components/shell/SectionFlyout.vue
Normal 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>
|
||||
Reference in New Issue
Block a user