From 3fde9575331429cb410396ea83cd0a88a2a4b86b Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 14 Jun 2026 18:45:11 +0200 Subject: [PATCH 1/4] feat(ui): rework shared header into a single app-bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two-row header (logo/utility row + nav row) into one app-bar, shared by both the ExtJS shell and the SolidJS /ui shell: - Primary nav (Time tracking, Overview, Evaluation) stays inline; secondary and role-gated items (Extras, Billing, Administration) plus Settings and Help fold into a "More" disclosure menu, with icon+text for Settings and Help. - The segmented System/Light/Dark toggle becomes a single theme-cycle button. A 3-state control can't use aria-pressed, so the current mode and next action are kept in a live-updating accessible name (WCAG 4.1.2). - The Month worktime badge now shows person-days only (e.g. "18.5 PT"); the hours move to the title. Today/Week stay in H:MM. (formatDays added to both the ExtJS main.js and the SolidJS header.ts, with a unit test.) - Below 880px the nav collapses behind a hamburger drawer (WCAG 1.4.10 reflow); the Month badge stays visible down to 480px. 44px targets and focus-visible rings throughout. Menu/drawer behaviour is wired framework-neutrally in a new header-behavior.html.twig (included by the header partial), theme cycling in theme-init.html.twig — both shells get identical behaviour. e2e: the secondary nav links now live in the "More" menu, so navigation helpers open it before clicking; goToSettings/Admin/BillingPage and the affected assertions are updated accordingly. Signed-off-by: Sebastian Mendel --- assets/js/main.js | 13 +- assets/styles/header.css | 454 ++++++++++++++----- e2e/error-handling.spec.ts | 4 +- e2e/helpers/navigation.ts | 16 + e2e/navigation.spec.ts | 5 + frontend/src/header.test.ts | 11 +- frontend/src/header.ts | 14 +- templates/partials/header-behavior.html.twig | 100 ++++ templates/partials/header.html.twig | 175 ++++--- templates/partials/theme-init.html.twig | 66 ++- translations/messages.de.yml | 4 + 11 files changed, 652 insertions(+), 210 deletions(-) create mode 100644 templates/partials/header-behavior.html.twig diff --git a/assets/js/main.js b/assets/js/main.js index 3280aee28..ccb346e10 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -243,6 +243,15 @@ function formatDuration(duration, inDays) { return text; } +/** + * Formats a duration as person-days only (e.g. '18.5 PT') for the Month badge. + */ +function formatDays(duration) { + const days = Math.floor(duration / (60 * 8) * 100) / 100; + + return days + ' PT'; +} + /* * Counts and displays worktime for today, this week and this month in the header */ @@ -254,7 +263,9 @@ function countTime() { const data = Ext.decode(response.responseText); Ext.get('worktime-day').update(formatDuration(data.today.duration, false)); Ext.get('worktime-week').update(formatDuration(data.week.duration, false)); - Ext.get('worktime-month').update(formatDuration(data.month.duration, true)); + // Month shows person-days only; hours stay in the title for reference. + Ext.get('worktime-month').update(formatDays(data.month.duration)); + Ext.get('worktime-month').set({ title: formatDuration(data.month.duration, false) }); } }); } diff --git a/assets/styles/header.css b/assets/styles/header.css index daa5801bf..6f50b3229 100644 --- a/assets/styles/header.css +++ b/assets/styles/header.css @@ -3,11 +3,16 @@ Loaded by BOTH the ExtJS shell and the SolidJS shell (/ui) — keep this file framework-neutral; markup lives in templates/partials/header.html.twig. + Layout: a single app-bar. Primary nav inline; secondary/role-gated items plus + Settings & Help fold into a "More" disclosure; worktime badges, a single + theme-cycle button and the user/logout cluster follow. Below 880px the nav + collapses behind a hamburger drawer (WCAG 1.4.10 reflow). + Theming: colours use light-dark() so the header follows the user's theme. - The SolidJS theme switch sets data-theme on ; we map that to - color-scheme here so light-dark() resolves on BOTH shells (the SPA design - tokens in app.css are not loaded on the ExtJS shell). With no explicit - choice the header follows the OS (prefers-color-scheme). + theme-init.html.twig sets data-theme on ; we map that to color-scheme + here so light-dark() resolves on BOTH shells (the SPA design tokens in app.css + are not loaded on the ExtJS shell). With no explicit choice the header follows + the OS (prefers-color-scheme). ========================================================================== */ .page-header { @@ -45,19 +50,18 @@ outline-offset: 2px; } -/* App Header - Flexbox Layout */ +/* App Header — single app-bar row */ .app-header { display: flex; align-items: center; - justify-content: space-between; - height: 80px; - padding: 0 16px; + min-height: 64px; + padding: 8px 16px; font-family: Arial, sans-serif; font-size: 13px; - gap: 16px; + gap: 12px; } -/* Logo Section */ +/* Logo */ .header-logo { flex-shrink: 0; display: flex; @@ -66,93 +70,142 @@ #logo { display: block; - max-height: 50px; - max-width: 200px; + max-height: 44px; + max-width: 180px; width: auto; height: auto; object-fit: contain; } -/* Theme switch slot (the SolidJS ThemeToggle is portalled in here on /ui). */ -.header-theme { +/* Pushes the right-hand cluster (worktime/theme/user) to the far edge. */ +.header-spacer { + flex: 1 1 auto; + min-width: 8px; +} + +/* ---- Primary navigation (inline) ---- */ +.main-nav { display: flex; align-items: center; + gap: 2px; flex-shrink: 0; } -/* User Badge (status + logout merged) */ -.user-badge { - display: flex; +.main-nav-link { + display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 4px; + min-height: 44px; + padding: 0 14px; + color: light-dark(#1f2937, #e6e9ec); + font-family: Arial, sans-serif; font-size: 13px; - font-weight: 500; - flex-shrink: 0; - transition: background-color 0.2s; + font-weight: 600; + text-decoration: none; + border-bottom: 3px solid transparent; + white-space: nowrap; } -.user-badge .status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; +.main-nav-link:hover, +.main-nav-link:focus-visible { + background-color: light-dark(rgba(0, 136, 154, 0.1), rgba(111, 208, 221, 0.15)); } -.user-badge .user-name { - white-space: nowrap; +.main-nav-link.is-active { + border-bottom-color: light-dark(#00889A, #6fd0dd); + color: light-dark(#00525c, #6fd0dd); } -.user-badge.status_active { - background-color: light-dark(#c1cbc1, #283330); - color: light-dark(#333, #e6e9ec); +.main-nav-link:focus-visible, +.nav-more-btn:focus-visible, +.theme-cycle:focus-visible, +.nav-burger:focus-visible, +.drawer-link:focus-visible { + outline: 2px solid light-dark(#00889A, #6fd0dd); + outline-offset: -2px; } -.user-badge.status_active .status-indicator { - background-color: #22c55e; - box-shadow: 0 0 4px rgba(34, 197, 94, 0.5); +/* ---- "More" overflow disclosure ---- */ +.nav-more { + position: relative; + display: flex; + align-items: center; } -.user-badge.status_inactive { - background-color: light-dark(#cfc1c1, #332828); - color: light-dark(#444, #e6e9ec); +.nav-more-btn { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 44px; + padding: 0 12px; + margin: 0; + background: transparent; + border: 0; + cursor: pointer; + font-family: Arial, sans-serif; + font-size: 13px; + font-weight: 600; + color: light-dark(#1f2937, #e6e9ec); } -.user-badge.status_inactive .status-indicator { - background-color: #9ca3af; +.nav-more-btn:hover { + background-color: light-dark(rgba(0, 136, 154, 0.1), rgba(111, 208, 221, 0.15)); } -.user-badge .badge-logout { - margin-left: 4px; - padding: 2px 8px; - font-size: 11px; - color: light-dark(#333, #c4c9cf); - text-decoration: none; - border: 1px solid light-dark(#ddd, #4a525b); - border-radius: 3px; - transition: all 0.2s; +.nav-more-btn[aria-expanded="true"] .nav-chevron { + transform: rotate(180deg); } -.user-badge .badge-logout:hover, -.user-badge .badge-logout:focus { - background-color: light-dark(#cfbdbd, #4a2a2c); - border-color: light-dark(#B00000, #e0888c); - color: light-dark(#8F0000, #f5bdc0); +.nav-chevron { + transition: transform 0.15s ease; } -.user-badge .badge-logout:focus { - outline: 2px solid #00889A; - outline-offset: 1px; +.nav-more-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 1000; + min-width: 220px; + padding: 6px; + background-color: light-dark(#ffffff, #1d2127); + border: 1px solid light-dark(#c3c8cd, #3c434b); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18); +} + +.nav-more-menu[hidden] { + display: none; +} + +.nav-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 0 12px; + border-bottom: 0; + border-radius: 7px; + box-sizing: border-box; +} + +.nav-menu-item.is-active { + border-bottom: 0; + background-color: light-dark(#e0f0f2, #0f3a40); + color: light-dark(#00525c, #6fd0dd); +} + +.nav-menu-ico { + flex-shrink: 0; + width: 18px; + height: 18px; + display: inline-block; } -/* Working Time Section */ +/* ---- Working time badges ---- */ .header-worktime { display: flex; align-items: center; - gap: 16px; - flex: 1; - justify-content: flex-end; + flex-shrink: 0; } .worktime-list { @@ -166,10 +219,12 @@ display: flex; flex-direction: column; align-items: center; + justify-content: center; + min-height: 44px; padding: 4px 12px; background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.07)); - border-radius: 4px; - min-width: 70px; + border-radius: 6px; + min-width: 68px; } .worktime-item dt { @@ -187,9 +242,14 @@ margin: 0; } -/* The whole month badge is a link to the monthly overview. The stays - inside
(so the
grouping is valid), but a stretched ::after overlay - makes the entire badge — label included — the click target. */ +.worktime-item-month dd a { + color: light-dark(#00525c, #6fd0dd); + font-weight: 700; +} + +/* The whole badge is a link to the monthly overview. The stays inside
+ (so the
grouping is valid), but a stretched ::after overlay makes the + entire badge — label included — the click target. */ .worktime-item-link { position: relative; cursor: pointer; @@ -210,7 +270,7 @@ content: ""; position: absolute; inset: 0; - border-radius: 4px; + border-radius: 6px; } .worktime-item-link:hover dd a { @@ -222,106 +282,248 @@ outline-offset: 1px; } -/* Theme toggle (segmented System / Light / Dark) — framework-neutral, lives in - the shared header so it works on both shells. Wired by theme-init.html.twig. - (.header-theme itself is styled once near the logo block above.) */ -.theme-toggle { - display: inline-flex; - border: 1px solid light-dark(#a9adb2, #3a414a); - border-radius: 6px; - overflow: hidden; - background-color: light-dark(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.04)); +/* ---- Theme cycle (single button: System → Light → Dark) ---- */ +.header-theme { + display: flex; + align-items: center; + flex-shrink: 0; } -.theme-toggle-item { - appearance: none; - border: 0; - background: transparent; +.theme-cycle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; margin: 0; - padding: 6px 10px; - min-height: 32px; - font: inherit; - font-size: 12px; + background-color: light-dark(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.04)); + border: 1px solid light-dark(#a9adb2, #3a414a); + border-radius: 8px; cursor: pointer; color: light-dark(#3a4047, #c4c9cf); transition: background-color 0.15s, color 0.15s; } -.theme-toggle-item + .theme-toggle-item { - border-left: 1px solid light-dark(#bcc0c5, #3a414a); +.theme-cycle:hover { + background-color: light-dark(rgba(0, 136, 154, 0.1), rgba(111, 208, 221, 0.12)); + color: light-dark(#00525c, #6fd0dd); } -.theme-toggle-item:hover { - background-color: light-dark(rgba(0, 136, 154, 0.1), rgba(111, 208, 221, 0.12)); +.theme-ico { + width: 20px; + height: 20px; } -.theme-toggle-item.is-active { - background-color: light-dark(#00889A, #6fd0dd); - color: light-dark(#fff, #11151a); - font-weight: 600; +.theme-ico[hidden] { + display: none; } -.theme-toggle-item:focus-visible { - outline: 2px solid light-dark(#00889A, #6fd0dd); - outline-offset: -2px; +/* ---- User cluster (status + logout) ---- */ +.header-account { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } -@media (prefers-reduced-motion: reduce) { - .theme-toggle-item, - .worktime-item-link { - transition: none; - } +.user-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + transition: background-color 0.2s; } -/* External Navigation iFrame */ -#nrnavi { - height: 30px; - width: 100%; - border: 0; - margin: 0; - display: block; +.user-badge .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; } -/* Main navigation - directly above the content/tab area, identical on both shells */ -.main-nav { - display: flex; - gap: 4px; - padding: 0 16px; - background-color: light-dark(#c6c9cc, #21262d); - border-top: 1px solid light-dark(#b5b8bc, #2f353d); - border-bottom: 1px solid light-dark(#9aa0a6, #11151a); +.user-badge .user-name { + white-space: nowrap; } -.main-nav-link { +.user-badge.status_active { + background-color: light-dark(#c1cbc1, #283330); + color: light-dark(#333, #e6e9ec); +} + +.user-badge.status_active .status-indicator { + background-color: #22c55e; + box-shadow: 0 0 4px rgba(34, 197, 94, 0.5); +} + +.user-badge.status_inactive { + background-color: light-dark(#cfc1c1, #332828); + color: light-dark(#444, #e6e9ec); +} + +.user-badge.status_inactive .status-indicator { + background-color: #9ca3af; +} + +.badge-logout { display: inline-flex; align-items: center; min-height: 44px; - padding: 0 16px; + padding: 0 12px; + font-size: 12px; + color: light-dark(#333, #c4c9cf); + text-decoration: none; + border: 1px solid light-dark(#ddd, #4a525b); + border-radius: 6px; + transition: all 0.2s; +} + +.badge-logout:hover, +.badge-logout:focus-visible { + background-color: light-dark(#cfbdbd, #4a2a2c); + border-color: light-dark(#B00000, #e0888c); + color: light-dark(#8F0000, #f5bdc0); +} + +.badge-logout:focus-visible { + outline: 2px solid #00889A; + outline-offset: 1px; +} + +/* ---- Hamburger + mobile drawer (shown < 880px) ---- */ +.nav-burger { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + margin: 0; + background: transparent; + border: 1px solid light-dark(#a9adb2, #3a414a); + border-radius: 8px; + cursor: pointer; + color: light-dark(#1f2937, #e6e9ec); + flex-shrink: 0; +} + +.mobile-drawer { + border-top: 1px solid light-dark(#b5b8bc, #2f353d); + background-color: light-dark(#e4e6e8, #21262d); + padding: 8px; +} + +.mobile-drawer[hidden] { + display: none; +} + +.drawer-nav { + display: flex; + flex-direction: column; +} + +.drawer-link { + display: flex; + align-items: center; + min-height: 48px; + padding: 0 12px; color: light-dark(#1f2937, #e6e9ec); font-family: Arial, sans-serif; - font-size: 13px; + font-size: 15px; font-weight: 600; text-decoration: none; - border-bottom: 3px solid transparent; + border-radius: 7px; + border-left: 3px solid transparent; } -.main-nav-link:hover, -.main-nav-link:focus { +.drawer-link:hover, +.drawer-link:focus-visible { background-color: light-dark(rgba(0, 136, 154, 0.1), rgba(111, 208, 221, 0.15)); } -.main-nav-link.is-active { - border-bottom-color: light-dark(#00889A, #6fd0dd); +.drawer-link.is-active { + border-left-color: light-dark(#00889A, #6fd0dd); color: light-dark(#00525c, #6fd0dd); } +.drawer-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + padding: 8px 6px 4px; + border-top: 1px solid light-dark(#b5b8bc, #2f353d); +} + +.drawer-user { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; +} + +.drawer-user .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #22c55e; +} + +/* External Navigation iFrame */ +#nrnavi { + height: 30px; + width: 100%; + border: 0; + margin: 0; + display: block; +} + +/* ========================================================================== + Responsive: collapse the nav behind the hamburger below 880px (WCAG 1.4.10). + The Month (PT) badge and the theme cycle stay in the bar; everything else + moves into the drawer. + ========================================================================== */ +@media (max-width: 880px) { + .main-nav, + .header-account, + .worktime-item:not(.worktime-item-month) { + display: none; + } + + .header-spacer { + flex: 0 0 auto; + min-width: 0; + } + + .header-worktime { + margin-left: auto; + } + + .nav-burger { + display: inline-flex; + } +} + +@media (max-width: 480px) { + .header-worktime { + display: none; + } +} + /* WCAG 2.3.3: honour reduced-motion for the header's own transitions (the SPA app.css has its own guard, but this file ships to both shells). */ @media (prefers-reduced-motion: reduce) { .skip-link, .user-badge, - .user-badge .badge-logout, - .worktime-link, + .badge-logout, + .worktime-item-link, + .theme-cycle, + .nav-chevron, .main-nav-link { transition: none; } diff --git a/e2e/error-handling.spec.ts b/e2e/error-handling.spec.ts index f3511bc66..80200f6e2 100644 --- a/e2e/error-handling.spec.ts +++ b/e2e/error-handling.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; import { login } from './helpers/auth'; import { waitForGrid } from './helpers/grid'; import { displayDateToIso } from './helpers/date'; +import { openMoreMenu } from './helpers/navigation'; /** * E2E tests for error handling and notifications. @@ -168,7 +169,8 @@ test.describe('Success Notifications', () => { }); test('should show success notification after settings save', async ({ page }) => { - // Settings moved to the SolidJS UI; reach it via the shared header nav link. + // Settings moved to the SolidJS UI; it lives in the header "More" menu. + await openMoreMenu(page); await page.locator('a.main-nav-link[data-nav="settings"]').click(); await page.waitForURL(/\/ui\/settings/, { timeout: 10000 }); await page.waitForSelector('form.stack-form', { timeout: 10000 }); diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts index 92e8b190d..4fd4159ac 100644 --- a/e2e/helpers/navigation.ts +++ b/e2e/helpers/navigation.ts @@ -43,6 +43,19 @@ export async function goToTab(page: Page, tabName: RegExp | string): Promise { + const button = page.locator('#nav-more-btn'); + if ((await button.getAttribute('aria-expanded')) !== 'true') { + await button.click(); + } + await page.locator('#nav-more-menu').waitFor({ state: 'visible' }); +} + /** * Navigate to Time Tracking tab */ @@ -73,6 +86,7 @@ export async function goToChartsTab(page: Page): Promise { * (Formerly the ExtJS Controlling/Abrechnung tab.) */ export async function goToBillingPage(page: Page): Promise { + await openMoreMenu(page); await page.locator(NAV_LINKS.billing).click(); await page.waitForURL(/\/ui\/billing/, { timeout: 10000 }); await page.waitForSelector('form.stack-form', { timeout: 10000 }); @@ -83,6 +97,7 @@ export async function goToBillingPage(page: Page): Promise { * (Formerly the ExtJS Settings/Einstellungen tab.) */ export async function goToSettingsPage(page: Page): Promise { + await openMoreMenu(page); await page.locator(NAV_LINKS.settings).click(); await page.waitForURL(/\/ui\/settings/, { timeout: 10000 }); await page.waitForSelector('form.stack-form', { timeout: 10000 }); @@ -93,6 +108,7 @@ export async function goToSettingsPage(page: Page): Promise { * (Formerly the ExtJS Administration tab; only visible to ROLE_ADMIN.) */ export async function goToAdminPage(page: Page): Promise { + await openMoreMenu(page); await page.locator(NAV_LINKS.admin).click(); await page.waitForURL(/\/ui\/admin/, { timeout: 10000 }); await page.waitForSelector('section.admin-page', { timeout: 10000 }); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 2e6db98e5..3bbda886e 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -6,6 +6,7 @@ import { goToSettingsPage, goToAdminPage, getVisibleTabs, + openMoreMenu, NAV_LINKS, } from './helpers/navigation'; import { waitForGrid } from './helpers/grid'; @@ -42,6 +43,8 @@ test.describe('Tab Navigation', () => { // Settings is no longer an ExtJS tab — it is reached via the header nav link. const hasSettingsTab = tabs.some((t) => /Einstellungen|Settings/i.test(t)); expect(hasSettingsTab).toBe(false); + // Settings now lives in the header "More" overflow menu. + await openMoreMenu(page); await expect(page.locator(NAV_LINKS.settings)).toBeVisible(); }); @@ -104,6 +107,8 @@ test.describe('Role-Based Tab Visibility', () => { const hasControllingTab = tabs.some((t) => /Controlling|Abrechnung/i.test(t)); expect(hasControllingTab).toBe(false); + // Administration and Billing live in the header "More" overflow menu. + await openMoreMenu(page); await expect(page.locator(NAV_LINKS.admin)).toBeVisible(); await expect(page.locator(NAV_LINKS.billing)).toBeVisible(); }); diff --git a/frontend/src/header.test.ts b/frontend/src/header.test.ts index d2239ce08..35371ae2e 100644 --- a/frontend/src/header.test.ts +++ b/frontend/src/header.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { formatDuration } from './header' +import { formatDays, formatDuration } from './header' describe('formatDuration', () => { it('formats minutes as H:MM like the ExtJS header', () => { @@ -16,3 +16,12 @@ describe('formatDuration', () => { expect(formatDuration(960)).toBe('16:00') }) }) + +describe('formatDays', () => { + it('formats minutes as person-days only for the Month badge', () => { + expect(formatDays(0)).toBe('0 PT') + expect(formatDays(480)).toBe('1 PT') + expect(formatDays(720)).toBe('1.5 PT') + expect(formatDays(8880)).toBe('18.5 PT') + }) +}) diff --git a/frontend/src/header.ts b/frontend/src/header.ts index 3c4096a31..ef84fa066 100644 --- a/frontend/src/header.ts +++ b/frontend/src/header.ts @@ -22,6 +22,13 @@ export function formatDuration(minutes: number, inDays = false): string { return inDays && days > 1 ? `${text} (${days} PT)` : text } +/** Person-days only (e.g. '18.5 PT') — used for the Month badge. */ +export function formatDays(minutes: number): string { + const days = Math.floor((minutes / (60 * 8)) * 100) / 100 + + return `${days} PT` +} + function setText(id: string, text: string): void { const element = document.getElementById(id) if (element !== null) { @@ -48,7 +55,12 @@ async function updateWorktime(): Promise { const summary = await getJson('/getTimeSummary') setText('worktime-day', formatDuration(summary.today.duration)) setText('worktime-week', formatDuration(summary.week.duration)) - setText('worktime-month', formatDuration(summary.month.duration, true)) + // Month shows person-days only; the hours stay in the title for reference. + setText('worktime-month', formatDays(summary.month.duration)) + const month = document.getElementById('worktime-month') + if (month !== null) { + month.title = formatDuration(summary.month.duration) + } } catch { // Header sums are non-critical; leave the rendered defaults. } diff --git a/templates/partials/header-behavior.html.twig b/templates/partials/header-behavior.html.twig new file mode 100644 index 000000000..cab3e834c --- /dev/null +++ b/templates/partials/header-behavior.html.twig @@ -0,0 +1,100 @@ +{# Framework-neutral header interactivity shared by BOTH shells (ExtJS and the + SolidJS /ui shell): the "More" overflow disclosure and the mobile hamburger + drawer. Theme cycling lives in theme-init.html.twig. Idempotent — guarded so + re-includes (or a shell that renders the header twice) wire handlers once. #} + diff --git a/templates/partials/header.html.twig b/templates/partials/header.html.twig index d1eeefb18..ee9bbd15f 100644 --- a/templates/partials/header.html.twig +++ b/templates/partials/header.html.twig @@ -3,9 +3,23 @@ viewport north region via contentEl) and the SolidJS shell (ui/index.html.twig). Required context: globalConfig, settings, apptitle, active. `active` marks the current item server-side; SPA routes refine it client-side - (frontend/src/nav.ts). settings.roles gates the role-restricted items. #} + (frontend/src/nav.ts). settings.roles gates the role-restricted items. + + Layout: a single app-bar. Primary items (Time tracking, Overview, Evaluation) + sit inline; secondary + role-gated items (Extras, Billing, Administration) plus + Settings and Help fold into a "More" disclosure menu. Worktime badges, a + single-button theme cycle and the user/logout cluster follow. Below ~880px the + nav collapses behind a hamburger drawer. Behaviour is wired framework-neutrally + by partials/header-behavior.html.twig (menu/drawer) and theme-init.html.twig + (theme cycle), so it works on BOTH shells. #} {% set isAdmin = 'ROLE_ADMIN' in settings.roles %} {% set canBill = isAdmin or 'ROLE_PL' in settings.roles %} +{# Each worktime badge deep-links to the Overview pre-scoped to that period. #} +{% set weekDays = [] %} +{% for i in 0..6 %} + {% set weekDays = weekDays|merge(["monday this week +#{i} days"|date('Y-m-d')]) %} +{% endfor %} +{% set monthPath = path('ui_spa', {'path': 'month'}) %} +{% include 'partials/header-behavior.html.twig' %} diff --git a/templates/partials/theme-init.html.twig b/templates/partials/theme-init.html.twig index 87146e8ab..d4841b24d 100644 --- a/templates/partials/theme-init.html.twig +++ b/templates/partials/theme-init.html.twig @@ -1,8 +1,8 @@ {# Framework-neutral light/dark theming shared by BOTH shells (the ExtJS shell and the SolidJS /ui shell). Applies the saved preference before first paint - (no flash) and, once the DOM is ready, wires the segmented toggle rendered in - the shared header (partials/header.html.twig). This is the single source of - truth for theming — neither shell needs framework-specific theme code. #} + (no flash) and, once the DOM is ready, wires the single-button theme cycle + rendered in the shared header (partials/header.html.twig). This is the single + source of truth for theming — neither shell needs framework-specific theme code. #} diff --git a/templates/partials/header.html.twig b/templates/partials/header.html.twig index ee9bbd15f..e0e8a4f44 100644 --- a/templates/partials/header.html.twig +++ b/templates/partials/header.html.twig @@ -100,7 +100,7 @@ icon and keeps an accessible name in sync. #}
From b4cf5985abecaf7fdf82255840717fba103f8305 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 14 Jun 2026 22:25:02 +0200 Subject: [PATCH 3/4] fix(ui): true priority-overflow nav, working theme icon, hover/close fixes Addresses review feedback on the app-bar header: - Theme icon now reflects the active mode. The icons are (SVGElement), which doesn't reflect the .hidden IDL property to the attribute, so the CSS [hidden] rule never toggled and the System icon showed in every mode. Toggle the attribute via set/removeAttribute instead. - "More" is now a genuine overflow control, not a fixed bucket. All nav items (incl. Extras/Billing/Administration/Settings/Help) start inline; the script folds them into "More" from the end only as the viewport narrows, and the whole control is hidden when nothing has overflowed. Settings/Help show their icon only once folded into the menu (plain text inline). - "More" opens on hover (in addition to click/keyboard) and no longer closes in the gap between button and menu: the menu is flush (top:100%) and a short close delay bridges the pointer move; it still closes on link-click, on leaving the control, on click-outside and on Escape. e2e: clickHeaderNav() reaches a nav link whether it's inline or folded into "More" (width-dependent); presence assertions use toBeAttached rather than asserting placement. Signed-off-by: Sebastian Mendel --- assets/styles/header.css | 13 +- e2e/error-handling.spec.ts | 8 +- e2e/helpers/navigation.ts | 27 ++-- e2e/navigation.spec.ts | 15 +- templates/partials/header-behavior.html.twig | 145 +++++++++++-------- templates/partials/header.html.twig | 55 +++---- templates/partials/theme-init.html.twig | 8 +- 7 files changed, 164 insertions(+), 107 deletions(-) diff --git a/assets/styles/header.css b/assets/styles/header.css index c57f5fb0e..685053e64 100644 --- a/assets/styles/header.css +++ b/assets/styles/header.css @@ -132,6 +132,11 @@ align-items: center; } +/* Overflow disclosure is empty (and hidden) until items fold into it. */ +.nav-more[hidden] { + display: none; +} + .nav-more-btn { display: inline-flex; align-items: center; @@ -162,7 +167,7 @@ .nav-more-menu { position: absolute; - top: calc(100% + 4px); + top: 100%; left: 0; z-index: 1000; min-width: 220px; @@ -202,6 +207,12 @@ display: inline-block; } +/* Settings/Help carry an icon only inside the "More" menu; inline in the bar + they show as plain text like the other items. */ +.main-nav-link:not(.nav-menu-item) .nav-menu-ico { + display: none; +} + /* ---- Working time badges ---- */ .header-worktime { display: flex; diff --git a/e2e/error-handling.spec.ts b/e2e/error-handling.spec.ts index 80200f6e2..bcdc5fab3 100644 --- a/e2e/error-handling.spec.ts +++ b/e2e/error-handling.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import { login } from './helpers/auth'; import { waitForGrid } from './helpers/grid'; import { displayDateToIso } from './helpers/date'; -import { openMoreMenu } from './helpers/navigation'; +import { clickHeaderNav } from './helpers/navigation'; /** * E2E tests for error handling and notifications. @@ -169,9 +169,9 @@ test.describe('Success Notifications', () => { }); test('should show success notification after settings save', async ({ page }) => { - // Settings moved to the SolidJS UI; it lives in the header "More" menu. - await openMoreMenu(page); - await page.locator('a.main-nav-link[data-nav="settings"]').click(); + // Settings moved to the SolidJS UI; reach it via the header nav (inline or + // folded into "More" depending on width). + await clickHeaderNav(page, 'a.main-nav-link[data-nav="settings"]'); await page.waitForURL(/\/ui\/settings/, { timeout: 10000 }); await page.waitForSelector('form.stack-form', { timeout: 10000 }); diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts index 4fd4159ac..a4ed4f353 100644 --- a/e2e/helpers/navigation.ts +++ b/e2e/helpers/navigation.ts @@ -44,9 +44,8 @@ export async function goToTab(page: Page, tabName: RegExp | string): Promise @@ -108,9 +108,9 @@
@@ -144,8 +144,8 @@ {{ 'Help'|trans }}
- {{ settings.user_name }} - {{ 'Logout'|trans }} + {{ settings.user_name }} + {{ 'Logout'|trans }}