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:
25
admin/src/app/theme.ts
Normal file
25
admin/src/app/theme.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Colour scheme (D-06, flagged assumption A6): the admin follows the system
|
||||
// preference only, with no toggle. The .dark class on the document element
|
||||
// swaps the design's dark tokens in main.css; the sidebar stays dark in both
|
||||
// modes and gains its #222b40 right border in dark mode.
|
||||
export const DARK_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
/**
|
||||
* Applies the current preference and follows changes. Returns the
|
||||
* unsubscribe function.
|
||||
*/
|
||||
export function applyColorScheme(win: Window = window): () => void {
|
||||
const root = win.document.documentElement
|
||||
if (typeof win.matchMedia !== 'function') {
|
||||
root.classList.remove('dark')
|
||||
return () => undefined
|
||||
}
|
||||
const query = win.matchMedia(DARK_QUERY)
|
||||
const apply = (dark: boolean): void => {
|
||||
root.classList.toggle('dark', dark)
|
||||
}
|
||||
apply(query.matches)
|
||||
const onChange = (event: MediaQueryListEvent): void => apply(event.matches)
|
||||
query.addEventListener('change', onChange)
|
||||
return () => query.removeEventListener('change', onChange)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
|
||||
import FormField from './FormField.vue'
|
||||
import { RELATION_MANAGER } from './registry'
|
||||
|
||||
// Two-column form grid (design screen 4): span left -> column 1, right ->
|
||||
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
|
||||
@@ -18,7 +19,7 @@ const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
||||
|
||||
function spanClass(field: FormFieldSchema): string {
|
||||
// The relation manager always takes the whole row (design screen 5).
|
||||
if (field.type === 'relation-manager') {
|
||||
if (field.type === RELATION_MANAGER) {
|
||||
return 'col-span-full'
|
||||
}
|
||||
switch (field.span) {
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface FieldControlProps {
|
||||
recordId?: number | null
|
||||
}
|
||||
|
||||
/** The relation manager's field type (design screen 5). */
|
||||
export const RELATION_MANAGER = 'relation-manager'
|
||||
|
||||
const renderers = new Map<string, Component>([
|
||||
['text', TextField],
|
||||
['textarea', TextareaField],
|
||||
@@ -41,17 +44,17 @@ const renderers = new Map<string, Component>([
|
||||
['switch', SwitchField],
|
||||
['checkbox', CheckboxField],
|
||||
['relation', RelationField],
|
||||
['relation-manager', RelationManager],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
const selfLabelled = new Set<string>(['switch', 'checkbox', 'relation-manager'])
|
||||
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
|
||||
|
||||
/**
|
||||
* Types that need a saved record and hold no form value: never rendered on
|
||||
* create, never part of the save body (D-05, design screen 5).
|
||||
*/
|
||||
const recordBound = new Set<string>(['relation-manager'])
|
||||
const recordBound = new Set<string>([RELATION_MANAGER])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? UnsupportedField
|
||||
|
||||
@@ -1,72 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import PluginRail from './PluginRail.vue'
|
||||
import SectionPanel from './SectionPanel.vue'
|
||||
import Breadcrumbs from './Breadcrumbs.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import Toast from '../ui/Toast.vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { currentUser } from '../../state/useAuth'
|
||||
import { activeEntry } from '../../state/useNavigation'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
|
||||
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
|
||||
// the plugin rail, the active plugin's section panel (hidden when the
|
||||
// sidebar is collapsed by the viewport or by the admin), and the 64px header
|
||||
// with breadcrumbs and the user menu. The toast queue is mounted here so
|
||||
// toasts survive navigation.
|
||||
const route = useRoute()
|
||||
const { collapsed, watchViewport } = useSidebar()
|
||||
|
||||
const stopViewport = watchViewport()
|
||||
onBeforeUnmount(stopViewport)
|
||||
|
||||
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
|
||||
|
||||
const section = computed(() => {
|
||||
const id = controllerIdFromPath(route.path)
|
||||
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
const user = currentUser.value
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const name = `${user.first_name} ${user.last_name}`.trim()
|
||||
return name === '' ? user.login : name
|
||||
})
|
||||
|
||||
const initials = computed(() =>
|
||||
displayName.value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join(''),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-bg text-text">
|
||||
<PluginRail />
|
||||
<SectionPanel v-if="active" :entry="active" />
|
||||
<SectionPanel v-if="active && !collapsed" :entry="active" />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
|
||||
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
|
||||
<span v-if="route.name === 'settings' || route.name === 'settings-form'" class="font-semibold">
|
||||
{{ t('backend::lang.nav.settings') }}
|
||||
</span>
|
||||
<template v-else-if="active">
|
||||
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
|
||||
<template v-if="section">
|
||||
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
|
||||
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
|
||||
<span
|
||||
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
>{{ initials }}</span
|
||||
>
|
||||
<span class="flex flex-col leading-tight">
|
||||
<span class="font-semibold">{{ displayName }}</span>
|
||||
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<header class="flex h-header shrink-0 items-center justify-between gap-4 border-b border-border bg-surface pr-6 pl-8">
|
||||
<Breadcrumbs />
|
||||
<UserMenu />
|
||||
</header>
|
||||
<main class="min-w-0 flex-1 px-8 py-7">
|
||||
<slot />
|
||||
|
||||
69
admin/src/components/shell/Breadcrumbs.vue
Normal file
69
admin/src/components/shell/Breadcrumbs.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
|
||||
import { t } from '../../app/i18n'
|
||||
import { recordCrumb } from '../../state/useBreadcrumbs'
|
||||
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
|
||||
import { settingsEntry } from '../../state/useSettings'
|
||||
|
||||
// Header breadcrumbs (design: Top header): plugin, controller and record
|
||||
// labels, e.g. "Płytarium › Albumy › Enigmatic". Parent crumbs are muted
|
||||
// links; the current crumb uses the text colour at 600 with
|
||||
// aria-current="page". Separators are 14px chevron-right icons.
|
||||
interface Crumb {
|
||||
label: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const crumbs = computed<Crumb[]>(() => {
|
||||
if (route.name === 'settings' || route.name === 'settings-form') {
|
||||
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
|
||||
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null
|
||||
if (entry) {
|
||||
out.push({ label: entry.label })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
|
||||
if (!plugin) {
|
||||
return []
|
||||
}
|
||||
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
|
||||
const id = controllerIdFromPath(route.path)
|
||||
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
|
||||
if (section) {
|
||||
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })
|
||||
}
|
||||
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
|
||||
out.push({ label: recordCrumb.value })
|
||||
}
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="min-w-0">
|
||||
<ol class="flex min-w-0 items-center gap-2">
|
||||
<li v-for="(crumb, index) in crumbs" :key="index" data-crumb class="flex min-w-0 items-center gap-2">
|
||||
<ChevronRight v-if="index > 0" :size="14" class="shrink-0 text-muted" aria-hidden="true" />
|
||||
<span
|
||||
v-if="index === crumbs.length - 1 || !crumb.to"
|
||||
:aria-current="index === crumbs.length - 1 ? 'page' : undefined"
|
||||
:class="index === crumbs.length - 1 ? 'font-semibold text-text' : 'text-muted'"
|
||||
class="truncate"
|
||||
>{{ crumb.label }}</span
|
||||
>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="crumb.to"
|
||||
class="truncate text-muted no-underline transition-colors duration-150 ease-out hover:text-text"
|
||||
>{{ crumb.label }}</RouterLink
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { PanelLeftClose } from '@lucide/vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import type { NavigationEntry } from '../../api/types'
|
||||
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
|
||||
import { iconFor } from '../../app/icons'
|
||||
import { t } from '../../app/i18n'
|
||||
import { setCollapsed } from '../../state/useSidebar'
|
||||
|
||||
// Section panel (design: Navigation shell, level 2): the active plugin's
|
||||
// permitted side menu, 224px, with the plugin label and a "Zwiń menu" button
|
||||
// in its header. Collapsing hides it and leaves only the rail (D-06).
|
||||
|
||||
const props = defineProps<{ entry: NavigationEntry }>()
|
||||
const route = useRoute()
|
||||
@@ -20,6 +26,11 @@ const items = computed(() =>
|
||||
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
|
||||
}),
|
||||
)
|
||||
|
||||
function collapse(): void {
|
||||
setCollapsed(true)
|
||||
void nextTick(() => document.querySelector<HTMLElement>('[data-sidebar-expand]')?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,8 +38,18 @@ const items = computed(() =>
|
||||
:aria-label="t('backend::lang.nav.sections')"
|
||||
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
|
||||
>
|
||||
<div class="mb-3.5 flex h-10 items-center px-2">
|
||||
<span class="text-[16px] font-bold">{{ entry.label }}</span>
|
||||
<div class="mb-3.5 flex h-10 items-center justify-between gap-2 pl-2">
|
||||
<span class="truncate text-[16px] font-bold">{{ entry.label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-sidebar-collapse
|
||||
:aria-label="t('backend::lang.nav.collapse')"
|
||||
:title="t('backend::lang.nav.collapse')"
|
||||
class="flex size-[30px] shrink-0 items-center justify-center rounded-tab text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
|
||||
@click="collapse"
|
||||
>
|
||||
<PanelLeftClose :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-for="link in items"
|
||||
@@ -36,7 +57,7 @@ const items = computed(() =>
|
||||
:to="link.to"
|
||||
: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-nav items-center gap-3 rounded-control px-3 no-underline"
|
||||
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline transition-colors duration-150 ease-out"
|
||||
>
|
||||
<component :is="link.icon" :size="18" aria-hidden="true" />
|
||||
<span>{{ link.item.label }}</span>
|
||||
|
||||
107
admin/src/components/shell/UserMenu.vue
Normal file
107
admin/src/components/shell/UserMenu.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
import { ChevronDown, LogOut } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { currentUser, logout } from '../../state/useAuth'
|
||||
|
||||
// User menu (design: Top header): a 44px button with the initials avatar,
|
||||
// the name above the role from /auth/me (both hidden at tablet width) and a
|
||||
// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and
|
||||
// e-mail and a Wyloguj item. Logging out always ends on the login screen,
|
||||
// even when the logout call fails (T-10-23).
|
||||
const router = useRouter()
|
||||
|
||||
const displayName = computed(() => {
|
||||
const user = currentUser.value
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const name = `${user.first_name} ${user.last_name}`.trim()
|
||||
return name === '' ? user.login : name
|
||||
})
|
||||
|
||||
/** First and last name initials, else the login's first letters. */
|
||||
const initials = computed(() => {
|
||||
const user = currentUser.value
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
return user.login.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
await logout(router)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The menu stays mounted when the user clears (logout); the shell itself
|
||||
unmounts on the login route. -->
|
||||
<DropdownMenuRoot :modal="false">
|
||||
<DropdownMenuTrigger
|
||||
data-user-menu
|
||||
:disabled="!currentUser"
|
||||
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
|
||||
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
|
||||
>
|
||||
<span
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
data-user-initials
|
||||
>{{ initials }}</span
|
||||
>
|
||||
<span class="flex flex-col leading-tight max-[1099px]:hidden" data-user-identity>
|
||||
<span class="font-semibold">{{ displayName }}</span>
|
||||
<span v-if="roleName" class="text-[12px] text-muted" data-user-role>{{ roleName }}</span>
|
||||
</span>
|
||||
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-user-menu-content
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-2 py-2">
|
||||
<span
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
>{{ initials }}</span
|
||||
>
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate font-bold">{{ displayName }}</span>
|
||||
<span class="truncate text-[13px] text-muted">{{ currentUser?.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuItem
|
||||
data-action="logout"
|
||||
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
|
||||
@select="onLogout"
|
||||
>
|
||||
<LogOut :size="18" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.auth.logout') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -2,11 +2,20 @@
|
||||
import { Check, CircleAlert, X } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { dismissToast, toasts } from '../../state/useToasts'
|
||||
|
||||
// Toast stack (design: Interactions, Save and Relation manager): one global
|
||||
// queue, most recent first, success as status and danger as alert. Toasts
|
||||
// fade and scale in over 200 ms (no decorative animation) and dismiss after
|
||||
// about 4 s or on the close button.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="toast"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
@@ -26,11 +35,11 @@ import { dismissToast, toasts } from '../../state/useToasts'
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
|
||||
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
|
||||
@click="dismissToast(toast.id)"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
@@ -3,12 +3,15 @@ import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// Dark mode follows the system preference before anything renders (A6).
|
||||
applyColorScheme()
|
||||
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||
// login screen is translated, then the session, then navigation.
|
||||
await loadStrings().catch(() => undefined)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
const user = ref<AdminProfile | null>(null)
|
||||
const expiresIn = ref<number | null>(null)
|
||||
@@ -57,11 +60,33 @@ export function clearUser(): void {
|
||||
expiresIn.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the session: POST /auth/logout (the server blacklists the token and
|
||||
* expires the HttpOnly cookie), then clears the in-memory user, navigation
|
||||
* and settings and routes to the login screen. The local state is cleared
|
||||
* and the login screen shown even when the call fails (T-10-23).
|
||||
*/
|
||||
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
try {
|
||||
await api.POST('/auth/logout')
|
||||
} catch {
|
||||
// Network failure: the session still ends on this device.
|
||||
} finally {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
}
|
||||
if (router) {
|
||||
await router.replace({ name: 'login' }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return {
|
||||
user: currentUser,
|
||||
expiresIn: readonly(expiresIn),
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
clearUser,
|
||||
}
|
||||
|
||||
16
admin/src/state/useBreadcrumbs.ts
Normal file
16
admin/src/state/useBreadcrumbs.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// The record crumb of the header breadcrumbs (design: Top header). The form
|
||||
// view owns the record's title; it publishes it here while it is mounted so
|
||||
// the shell can show "Plugin › Controller › Record".
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
const record = ref('')
|
||||
|
||||
export const recordCrumb = readonly(record)
|
||||
|
||||
export function setRecordCrumb(label: string): void {
|
||||
record.value = label
|
||||
}
|
||||
|
||||
export function clearRecordCrumb(): void {
|
||||
record.value = ''
|
||||
}
|
||||
71
admin/src/state/useSidebar.ts
Normal file
71
admin/src/state/useSidebar.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Sidebar collapsed state (design: Navigation shell, Collapsed / tablet;
|
||||
// D-06, D-10). The section panel hides below about 1100px or when the admin
|
||||
// collapses it. The viewport only forces the collapse and never overwrites
|
||||
// the admin's own choice, which persists in localStorage as a boolean under
|
||||
// SIDEBAR_KEY. Nothing else is ever written to browser storage (T-10-22).
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
export const SIDEBAR_KEY = 'summer-admin.sidebar'
|
||||
export const NARROW_QUERY = '(max-width: 1099px)'
|
||||
|
||||
const manual = ref(false)
|
||||
const narrow = ref(false)
|
||||
|
||||
/** The panel is hidden: forced by the viewport or chosen by the admin. */
|
||||
export const sidebarCollapsed = computed(() => narrow.value || manual.value)
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readPreference(): boolean {
|
||||
try {
|
||||
return storage()?.getItem(SIDEBAR_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the admin's choice; only 'true' or 'false' is stored. */
|
||||
export function setCollapsed(value: boolean): void {
|
||||
manual.value = value
|
||||
try {
|
||||
storage()?.setItem(SIDEBAR_KEY, value ? 'true' : 'false')
|
||||
} catch {
|
||||
// Storage may be full or disabled; the choice still holds for the session.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the stored preference and follows the viewport breakpoint. Returns
|
||||
* the unsubscribe function; the shell calls it when it unmounts.
|
||||
*/
|
||||
export function watchViewport(): () => void {
|
||||
manual.value = readPreference()
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
narrow.value = false
|
||||
return () => undefined
|
||||
}
|
||||
const query = window.matchMedia(NARROW_QUERY)
|
||||
narrow.value = query.matches
|
||||
const onChange = (event: MediaQueryListEvent): void => {
|
||||
narrow.value = event.matches
|
||||
}
|
||||
query.addEventListener('change', onChange)
|
||||
return () => query.removeEventListener('change', onChange)
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
return {
|
||||
collapsed: sidebarCollapsed,
|
||||
/** The viewport is below the breakpoint; expanding is not possible. */
|
||||
narrow: readonly(narrow),
|
||||
manual: readonly(manual),
|
||||
setCollapsed,
|
||||
watchViewport,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Global toast queue (design: State Management). Toasts outlive route
|
||||
// changes because AppShell mounts the renderer, so "Zapisz i zamknij" can
|
||||
// show its toast on the list it returns to.
|
||||
// show its toast on the list it returns to. One queue; the most recent toast
|
||||
// comes first.
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
export type ToastTone = 'success' | 'danger'
|
||||
@@ -22,7 +23,7 @@ export const toasts = readonly(items)
|
||||
|
||||
export function showToast(text: string, tone: ToastTone = 'success'): number {
|
||||
const id = nextId++
|
||||
items.value = [...items.value, { id, text, tone }]
|
||||
items.value = [{ id, text, tone }, ...items.value]
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), TOAST_TIMEOUT),
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--c-bg: #111726;
|
||||
--c-surface: #182033;
|
||||
--c-subtle: #1f283d;
|
||||
@@ -164,3 +165,56 @@
|
||||
color: var(--c-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion (design: Interactions, Transitions): subtle only. Hover and
|
||||
background changes use the 150 ms ease-out utilities on each control;
|
||||
dialogs and their backdrop fade in and scale from 0.98 over 200 ms; toasts
|
||||
fade in the same way. Closing is immediate, so Reka's presence never waits
|
||||
on an exit animation. No decorative animation. */
|
||||
@keyframes summer-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes summer-dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0.98;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Backdrops of the picker (data-dialog-overlay) and of ConfirmDialog. */
|
||||
.bg-overlay[data-state="open"] {
|
||||
animation: summer-fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
[data-dialog-content][data-state="open"],
|
||||
[role="alertdialog"][data-state="open"] {
|
||||
animation: summer-dialog-in 200ms ease-out;
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
transition:
|
||||
opacity 200ms ease-out,
|
||||
scale 200ms ease-out;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
scale: 0.98;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg-overlay,
|
||||
[data-dialog-content],
|
||||
[role="alertdialog"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
|
||||
@@ -299,6 +300,10 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
|
||||
|
||||
// The header's last breadcrumb is this record's title (design: Top header).
|
||||
watchEffect(() => setRecordCrumb(loading.value ? '' : title.value))
|
||||
onBeforeUnmount(clearRecordCrumb)
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,3 +4,18 @@ const meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'summer-admin-base')
|
||||
meta.setAttribute('content', '/admin-test')
|
||||
document.head.appendChild(meta)
|
||||
|
||||
// happy-dom's default viewport is 1024px wide, below the 1100px sidebar
|
||||
// breakpoint. Tests get a desktop, light-mode media environment; the shell
|
||||
// smoke test spies on matchMedia to simulate narrow or dark screens.
|
||||
window.matchMedia = (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as MediaQueryList
|
||||
|
||||
339
admin/tests/smoke/shell.smoke.test.ts
Normal file
339
admin/tests/smoke/shell.smoke.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
|
||||
import { clearUser, currentUser } from '../../src/state/useAuth'
|
||||
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { NARROW_QUERY, SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
import { clearToasts, showToast } from '../../src/state/useToasts'
|
||||
import lang from '../fixtures/lang.json'
|
||||
import formSchema from '../fixtures/widgets.form-schema.json'
|
||||
import record from '../fixtures/widgets.record.json'
|
||||
import listSchema from '../fixtures/widgets.list-schema.json'
|
||||
import listRows from '../fixtures/widgets.list.json'
|
||||
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchema },
|
||||
[`GET ${BASE}`]: { body: listRows },
|
||||
[`GET ${BASE}/schema/form`]: { body: formSchema },
|
||||
[`GET ${BASE}/1`]: { body: record },
|
||||
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
/** A matchMedia whose answers the test controls, with change events. */
|
||||
function fakeMedia(initial: Record<string, boolean>) {
|
||||
const state = { ...initial }
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
const of = (query: string): Set<Listener> => {
|
||||
let set = listeners.get(query)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
listeners.set(query, set)
|
||||
}
|
||||
return set
|
||||
}
|
||||
const impl = (query: string): MediaQueryList =>
|
||||
({
|
||||
get matches() {
|
||||
return state[query] ?? false
|
||||
},
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: Listener) => of(query).add(listener),
|
||||
removeEventListener: (_type: string, listener: Listener) => of(query).delete(listener),
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation(impl)
|
||||
return {
|
||||
change(query: string, matches: boolean): void {
|
||||
state[query] = matches
|
||||
for (const listener of of(query)) {
|
||||
listener({ matches, media: query } as MediaQueryListEvent)
|
||||
}
|
||||
},
|
||||
listeners: (query: string) => of(query).size,
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function panel(wrapper: VueWrapper) {
|
||||
return wrapper.find('aside')
|
||||
}
|
||||
|
||||
function railItem(wrapper: VueWrapper, code = 'demo'): HTMLElement {
|
||||
return wrapper.find(`[data-rail-item="${code}"]`).element as HTMLElement
|
||||
}
|
||||
|
||||
function flyout(): HTMLElement | null {
|
||||
return document.body.querySelector('[data-section-flyout]')
|
||||
}
|
||||
|
||||
function key(target: Element, name: string): void {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.documentElement.classList.remove('dark')
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
// Registered after the cleanup above, so it runs first (hooks run as a
|
||||
// stack): the app unmounts, with its timers and portals, before the body is
|
||||
// cleared.
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('collapsible section panel', () => {
|
||||
it('persists the manual choice as a boolean under summer-admin.sidebar and restores it', async () => {
|
||||
const first = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
||||
expect(panel(first.wrapper).exists()).toBe(true)
|
||||
expect(panel(first.wrapper).text()).toContain('Demo')
|
||||
expect(first.wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
|
||||
await first.wrapper.find('[data-sidebar-collapse]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(panel(first.wrapper).exists()).toBe(false)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
expect(document.activeElement?.hasAttribute('data-sidebar-expand')).toBe(true)
|
||||
first.wrapper.unmount()
|
||||
|
||||
const second = await mountApp('/acme/demo/widgets', routes())
|
||||
expect(panel(second.wrapper).exists()).toBe(false)
|
||||
const expand = second.wrapper.find('[data-sidebar-expand]')
|
||||
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
|
||||
await expand.trigger('click')
|
||||
expect(panel(second.wrapper).exists()).toBe(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
// The sidebar preference is the only thing in browser storage.
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
})
|
||||
|
||||
it('collapses below 1100px without overwriting the stored preference', async () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'false')
|
||||
const media = fakeMedia({ [NARROW_QUERY]: true })
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||
|
||||
expect(panel(wrapper).exists()).toBe(false)
|
||||
// Expanding is impossible at this width, so no expand button.
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
|
||||
media.change(NARROW_QUERY, false)
|
||||
await flushPromises()
|
||||
expect(panel(wrapper).exists()).toBe(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
|
||||
wrapper.unmount()
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('section flyout', () => {
|
||||
it('opens from a focused rail item with Enter, moves by arrows, and Esc returns focus', async () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
||||
const item = railItem(wrapper)
|
||||
expect(item.getAttribute('aria-haspopup')).toBe('menu')
|
||||
expect(item.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
item.focus()
|
||||
await flushPromises()
|
||||
expect(flyout()).not.toBeNull()
|
||||
key(item, 'Enter')
|
||||
await flushPromises()
|
||||
|
||||
const menu = flyout()!
|
||||
expect(menu.getAttribute('role')).toBe('menu')
|
||||
expect(menu.getAttribute('aria-label')).toBe('Demo')
|
||||
expect(item.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(item.getAttribute('aria-controls')).toBe(menu.id)
|
||||
const items = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(items.map((element) => element.textContent?.trim())).toEqual(['Widgets', 'Gadgets'])
|
||||
expect(items[0]!.getAttribute('aria-current')).toBe('page')
|
||||
expect(document.activeElement).toBe(items[0])
|
||||
|
||||
key(items[0]!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(items[1])
|
||||
|
||||
key(items[1]!, 'Escape')
|
||||
await flushPromises()
|
||||
expect(flyout()).toBeNull()
|
||||
expect(document.activeElement).toBe(item)
|
||||
// Returning focus does not reopen it.
|
||||
await wait(250)
|
||||
expect(flyout()).toBeNull()
|
||||
})
|
||||
|
||||
it('opens with ArrowDown and on hover, and closes about 200 ms after mouse-leave', async () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
||||
|
||||
const tools = railItem(wrapper, 'tools')
|
||||
tools.focus()
|
||||
key(tools, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(flyout()!.getAttribute('aria-label')).toBe('Tools')
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('Hammers')
|
||||
key(document.activeElement!, 'Escape')
|
||||
await flushPromises()
|
||||
|
||||
const item = wrapper.find('[data-rail-item="demo"]')
|
||||
await item.trigger('mouseenter')
|
||||
expect(flyout()!.getAttribute('aria-label')).toBe('Demo')
|
||||
await item.trigger('mouseleave')
|
||||
await wait(100)
|
||||
expect(flyout()).not.toBeNull()
|
||||
// Moving onto the flyout keeps it open.
|
||||
flyout()!.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
await wait(250)
|
||||
expect(flyout()).not.toBeNull()
|
||||
flyout()!.dispatchEvent(new MouseEvent('mouseleave'))
|
||||
await wait(250)
|
||||
await flushPromises()
|
||||
expect(flyout()).toBeNull()
|
||||
})
|
||||
|
||||
it('never opens while the panel is expanded', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
||||
const item = railItem(wrapper)
|
||||
item.focus()
|
||||
key(item, 'Enter')
|
||||
await wrapper.find('[data-rail-item="demo"]').trigger('mouseenter')
|
||||
await flushPromises()
|
||||
|
||||
expect(flyout()).toBeNull()
|
||||
expect(item.hasAttribute('aria-haspopup')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('header', () => {
|
||||
it('shows plugin, controller and record crumbs on a record route', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
|
||||
const crumbs = wrapper.findAll('header [data-crumb]')
|
||||
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets', 'Blue widget'])
|
||||
expect(crumbs[0]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
|
||||
expect(crumbs[1]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
|
||||
expect(crumbs[2]!.find('a').exists()).toBe(false)
|
||||
expect(crumbs[2]!.find('[aria-current="page"]').text()).toBe('Blue widget')
|
||||
expect(wrapper.findAll('header [data-crumb] svg')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ends on the controller crumb on a list route', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||
|
||||
const crumbs = wrapper.findAll('header [data-crumb]')
|
||||
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets'])
|
||||
expect(crumbs[1]!.find('[aria-current="page"]').text()).toBe('Widgets')
|
||||
})
|
||||
|
||||
it('shows initials, name and role, and the menu with the e-mail and Wyloguj', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
||||
|
||||
const trigger = wrapper.find('[data-user-menu]')
|
||||
expect(trigger.find('[data-user-initials]').text()).toBe('DD')
|
||||
expect(trigger.find('[data-user-identity]').text()).toContain('Dana Dev')
|
||||
expect(trigger.find('[data-user-role]').text()).toBe('Developer')
|
||||
expect(trigger.find('[data-user-identity]').classes()).toContain('max-[1099px]:hidden')
|
||||
|
||||
await trigger.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const menu = document.body.querySelector('[data-user-menu-content]')!
|
||||
expect(menu.getAttribute('role')).toBe('menu')
|
||||
expect(menu.textContent).toContain('dev@example.test')
|
||||
expect(menu.querySelector('[data-action="logout"]')!.getAttribute('role')).toBe('menuitem')
|
||||
expect(menu.querySelector('[data-action="logout"]')!.textContent?.trim()).toBe('Wyloguj')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
||||
['fails', { status: 500, body: { error: { code: 'server_error', message: 'Boom', details: {} } } }],
|
||||
])('logs out when the call %s: POST /auth/logout, state cleared, login screen', async (_name, reply) => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`POST ${API}/auth/logout`]: reply }), {
|
||||
attach: true,
|
||||
})
|
||||
|
||||
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const [post] = requestsTo(calls, 'POST', `${API}/auth/logout`)
|
||||
expect(post).toBeDefined()
|
||||
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(navigation.value).toEqual([])
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('logs out to the login screen even when the network fails', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets', routes({
|
||||
[`POST ${API}/auth/logout`]: () => {
|
||||
throw new TypeError('network down')
|
||||
},
|
||||
}), { attach: true })
|
||||
|
||||
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme and toasts', () => {
|
||||
it('toggles the dark class from prefers-color-scheme and follows changes', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: true })
|
||||
const stop = applyColorScheme()
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
media.change(DARK_QUERY, true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
stop()
|
||||
expect(media.listeners(DARK_QUERY)).toBe(0)
|
||||
})
|
||||
|
||||
it('stays light when the system prefers light', () => {
|
||||
fakeMedia({ [DARK_QUERY]: false })
|
||||
applyColorScheme()()
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the most recent toast first', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||
showToast('First')
|
||||
showToast('Second', 'danger')
|
||||
await flushPromises()
|
||||
|
||||
const texts = wrapper.findAll('[data-tone]').map((toast) => toast.text())
|
||||
expect(texts[0]).toContain('Second')
|
||||
expect(texts[1]).toContain('First')
|
||||
})
|
||||
})
|
||||
1
boardwalk/dist/assets/index-BBZKA2Yr.css
vendored
Normal file
1
boardwalk/dist/assets/index-BBZKA2Yr.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
boardwalk/dist/assets/index-BC0qEqbI.js
vendored
3
boardwalk/dist/assets/index-BC0qEqbI.js
vendored
File diff suppressed because one or more lines are too long
3
boardwalk/dist/assets/index-BQShvn7O.js
vendored
Normal file
3
boardwalk/dist/assets/index-BQShvn7O.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-wkQq4mI5.css
vendored
1
boardwalk/dist/assets/index-wkQq4mI5.css
vendored
File diff suppressed because one or more lines are too long
4
boardwalk/dist/index.html
vendored
4
boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BC0qEqbI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-wkQq4mI5.css">
|
||||
<script type="module" crossorigin src="./assets/index-BQShvn7O.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BBZKA2Yr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user