feat(layout): migrate to full-width topbar with mobile drawer navigation

Move topbar outside the grid container as a standalone sticky header
(Shopify/Stripe pattern). Sidebar and icon bar use position:sticky
relative to topbar height. Mobile uses drawer pattern with backdrop,
focus trap, ESC close, and scroll lock. Tenant branding moved from
sidebar header into the topbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 22:21:39 +02:00
parent fe88e748a3
commit a3f270408b
13 changed files with 543 additions and 340 deletions

View File

@@ -320,12 +320,12 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
<body>
<a class="app-skip-link" href="#app-main-content"><?php e(t('Skip to main content')); ?></a>
<?php require __DIR__ . '/partials/app-topbar.phtml'; ?>
<div class="app-container">
<?php require __DIR__ . '/partials/app-main-aside-icon-bar.phtml'; ?>
<?php require __DIR__ . '/partials/app-main-aside.phtml'; ?>
<div class="app-main">
<main>
<?php require __DIR__ . '/partials/app-topbar.phtml'; ?>
<div id="app-main-content" class="app-main-content" tabindex="-1">
<?php Buffer::get('html'); ?>
</div>
@@ -333,6 +333,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
</div>
</div>
<div class="app-drawer-backdrop" aria-hidden="true"></div>
<script src="<?php e(assetVersion('vendor/fslightbox/fslightbox.js')); ?>"></script>
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>

View File

@@ -25,9 +25,8 @@ $docsDefaultPath = 'admin/docs/' . $docsDefaultSlug;
$hasOrganization = $canViewTenants || $canViewDepartments || $canViewUsers;
$hasUsersSection = $canViewRoles || $canViewPermissions;
$hasAutomationSection = $canViewImports || $canViewJobs;
$hasMonitoringSection = $canViewStats;
$hasLogsSection = $canViewMailLog;
$hasSystemSection = $canViewSettings;
$hasSystemSection = $canViewSettings || $canViewStats;
$hasAdminPanel = layoutHasAdminPanel($layoutAuth);
// Declarative nav config for admin panel groups
@@ -105,21 +104,6 @@ $adminNavGroups = [
],
],
],
[
'key' => 'admin-monitoring',
'label' => t('Monitoring'),
'icon' => 'bi-graph-up',
'visible' => $hasMonitoringSection,
'items' => [
[
'label' => t('Statistics'),
'path' => 'admin/stats',
'active' => navActive('admin/stats', true),
'visible' => $canViewStats,
'withTenant' => false,
],
],
],
[
'key' => 'admin-logs',
'label' => t('Logs'),
@@ -148,6 +132,13 @@ $adminNavGroups = [
'visible' => $canViewSettings,
'withTenant' => false,
],
[
'label' => t('Statistics'),
'path' => 'admin/stats',
'active' => navActive('admin/stats', true),
'visible' => $canViewStats,
'withTenant' => false,
],
],
],
];
@@ -281,24 +272,6 @@ $moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleS
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
?>
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
<?php if ($currentTenant): ?>
<div class="app-sidebar-tenant-logo">
<a href="<?php e(lurl('')); ?>">
<?php if ($hasTenantAvatar): ?>
<img src="auth/tenant-avatar-file?uuid=<?php e($tenantUuid); ?>&size=256" alt="<?php e($tenantName); ?>"
title="<?php e($tenantName); ?>">
<?php else: ?>
<div class="app-sidebar-tenant-name">
<?php e($tenantName); ?>
</div>
<?php endif; ?>
</a>
<button type="button" class="app-sidebar-tenant-toggle" data-sidebar-toggle
aria-label="<?php e(t('Toggle Sidebar')); ?>">
<i class="bi bi-list"></i>
</button>
</div>
<?php endif; ?>
<div id="app-sidebar-panels" class="app-sidebar-panels">
<nav id="aside-panel-explorer" class="app-sidebar-panel" data-aside-panel="explorer"
data-aside-title="<?php e(t('Explorer')); ?>" role="tabpanel" aria-labelledby="aside-tab-explorer"

View File

@@ -11,6 +11,13 @@ $theme = currentTheme();
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
// Tenant branding data (from $layoutNav, same source as app-main-aside.phtml)
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$tenantAvatar = is_array($layoutNav['tenantAvatar'] ?? null) ? $layoutNav['tenantAvatar'] : [];
$brandTenantUuid = trim((string) ($tenantAvatar['uuid'] ?? ''));
$brandTenantName = trim((string) ($tenantAvatar['name'] ?? ''));
$brandHasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
// Tenant switcher data
$currentTenant = $_SESSION['current_tenant'] ?? null;
$currentTenantId = (int) ($currentTenant['id'] ?? 0);
@@ -30,6 +37,22 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
<header class="app-header">
<nav class="app-topbar">
<div class="app-topbar-left">
<button type="button" class="app-topbar-hamburger" data-sidebar-toggle
aria-expanded="false"
aria-label="<?php e(t('Open navigation')); ?>"
data-open-label="<?php e(t('Open navigation')); ?>"
data-close-label="<?php e(t('Close navigation')); ?>">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a href="<?php e(lurl('')); ?>" class="app-topbar-brand">
<?php if ($brandHasTenantAvatar): ?>
<img src="auth/tenant-avatar-file?uuid=<?php e($brandTenantUuid); ?>&amp;size=256" alt="<?php e($brandTenantName); ?>">
<?php elseif ($brandTenantName !== ''): ?>
<span class="app-topbar-brand-name"><?php e($brandTenantName); ?></span>
<?php else: ?>
<span class="app-topbar-brand-name"><?php e(appTitle()); ?></span>
<?php endif; ?>
</a>
<?php
if (is_array($breadcrumbs ?? null) && $breadcrumbs !== []) {
require __DIR__ . '/app-breadcrumb.phtml';
@@ -45,11 +68,27 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
</button>
</div>
<ul class="app-topbar-right">
<?php foreach ($moduleTopbarSlots as $slot): ?>
<?php
if (!is_array($slot)) {
continue;
}
$slotPermission = trim((string) ($slot['permission'] ?? ''));
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
continue;
}
$slotTemplate = trim((string) ($slot['template'] ?? ''));
if ($slotTemplate === '' || !is_file($slotTemplate)) {
continue;
}
include $slotTemplate;
?>
<?php endforeach; ?>
<?php if ($canSwitchTenant): ?>
<li class="app-topbar-tenant-slot" data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
<details class="dropdown app-topbar-tenant-menu">
<summary class="app-topbar-tenant-chip" aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
<span class="app-topbar-tenant-name"><?php e($tenantLabel); ?></span>
<li data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
<details class="dropdown app-topbar-tenant-menu" data-summary-chevron="none">
<summary aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
<i class="bi bi-building" aria-hidden="true"></i>
</summary>
<ul class="app-topbar-menu-list app-topbar-menu-list--right">
<?php foreach ($availableTenants as $tenant): ?>
@@ -84,24 +123,8 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
</details>
</li>
<?php endif; ?>
<?php foreach ($moduleTopbarSlots as $slot): ?>
<?php
if (!is_array($slot)) {
continue;
}
$slotPermission = trim((string) ($slot['permission'] ?? ''));
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
continue;
}
$slotTemplate = trim((string) ($slot['template'] ?? ''));
if ($slotTemplate === '' || !is_file($slotTemplate)) {
continue;
}
include $slotTemplate;
?>
<?php endforeach; ?>
<li>
<details class="dropdown app-topbar-user-menu" data-app-component="theme-controls"
<details class="dropdown app-topbar-user-menu" data-summary-chevron="none" data-app-component="theme-controls"
<?php if ($allowUserTheme): ?>
data-theme-menu
data-theme-url="admin/users/theme"
@@ -109,7 +132,7 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
data-csrf-token="<?php e($csrfToken); ?>"
<?php endif; ?>>
<summary aria-label="<?php e(t('Account')); ?>">
<i class="bi bi-person-fill"></i>
<i class="bi bi-person"></i>
</summary>
<ul class="app-topbar-menu-list app-topbar-menu-list--right">
<li class="app-topbar-menu-heading" role="presentation"><small><?php e(t('Navigation')); ?></small></li>