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

View File

@@ -1,13 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
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 { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
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 { collapsed, narrow, setCollapsed } = useSidebar()
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,
})),
)
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>
<template>
@@ -36,22 +150,56 @@ const items = computed(() =>
<RouterLink
v-for="item in items"
:key="item.entry.code"
:ref="(instance) => setItemElement(item.entry.code, instance)"
:to="item.to"
:data-rail-item="item.entry.code"
: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="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" />
<span>{{ item.entry.label }}</span>
</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" />
<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
v-if="hasSettings"
to="/settings"
data-rail-settings
: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="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" />
<span>{{ t('backend::lang.nav.settings') }}</span>