209 lines
7.2 KiB
Vue
209 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { RouterLink, useRoute } from 'vue-router'
|
|
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 ?? '')))
|
|
|
|
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
|
|
// manage at least one settings page.
|
|
const inSettings = computed(() => route.path === '/settings' || route.path.startsWith('/settings/'))
|
|
|
|
const items = computed(() =>
|
|
railEntries.value.map((entry) => ({
|
|
entry,
|
|
to: firstControllerPath(entry) ?? '/',
|
|
icon: iconFor(entry.icon),
|
|
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>
|
|
<nav
|
|
:aria-label="t('backend::lang.nav.plugins')"
|
|
class="flex w-rail shrink-0 flex-col items-center gap-1.5 border-r border-side-border bg-side pt-3.5 pb-3"
|
|
>
|
|
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
|
|
<Sun :size="22" class="text-accent" aria-hidden="true" />
|
|
</span>
|
|
<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-[84px] 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 class="max-w-full [overflow-wrap:anywhere]">{{ 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-[84px] 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 class="max-w-full [overflow-wrap:anywhere]">{{ t('backend::lang.nav.settings') }}</span>
|
|
</RouterLink>
|
|
</nav>
|
|
</template>
|