소스 검색

Phase 14: hamburger menu groups admin utilities + Sign out

The site header rendered every nav link flat: Sprints, Workers,
Users, New sprint, Audit log, display name, and Sign out — six
hit-targets that wrap or compress the display name at laptop
widths. Workers / Users / Audit log are administrative concerns,
not the primary workflow (opening a sprint), but they shared
visual weight with the real actions. Sign out is housekeeping and
deserves even less real estate.

This phase tucks Workers, Users, Audit log, Sign out into a
hamburger dropdown on the right of the header. Sprints, New
sprint, and the user badge stay inline. Sign out remains a native
form submit with the CSRF hidden input — no JS-driven POST.

views/layout.php:
- Primary links now just Sprints + (admin) New sprint; the user
  badge is unchanged.
- New `<button data-menu-trigger>` with an inline 20×20 SVG
  hamburger (three `<line>`s, `stroke-current`). No external
  asset, no font icon — CSP-clean. `aria-expanded="false"`,
  `aria-haspopup="true"`, `aria-controls="app-menu"`,
  `aria-label="Open menu"`.
- New `<div id="app-menu" data-menu role="menu" hidden>`
  absolutely positioned below the trigger, min-w-[12rem] with
  rounded border, white bg, shadow-lg. Items are
  `px-3 py-2 text-sm text-slate-700 hover:bg-slate-50` plus a
  focus-ring matching the trigger.
- Admins see `<a role="menuitem">Workers</a>`,
  `<a role="menuitem">Users</a>`,
  `<a role="menuitem">Audit log</a>`, an `<hr>`, then the Sign-out
  form. Non-admins see only the Sign-out form (no divider). The
  hamburger renders whenever a user is signed in — one item reads
  fine inside the dropdown and it keeps the two branches
  structurally identical.
- The Sign-out submit keeps `<form method="post"
  action="/auth/logout">` with the `_csrf` hidden input; the
  button gets `role="menuitem"` plus `block w-full text-left
  font-[inherit]` so it reads as a menu row.

public/assets/js/app.js:
- Adds a tiny vanilla-JS IIFE (~30 lines) for the menu. No
  jQuery — the existing `data-href` block stays jQuery-backed as
  before; the new controller is `document.querySelector` +
  `addEventListener`.
- `[data-menu-trigger]` toggles `[data-menu]`'s `hidden` +
  flips `aria-expanded`. Closes on:
    * outside-click (ignores clicks inside the panel or on the
      trigger itself; mirrors the owner-filter / columns pattern
      in sprint-planner.js);
    * Escape (calls `trigger.focus()` so keyboard users don't lose
      their place);
    * any `role="menuitem"` click (so a link navigation or the
      sign-out form submit closes the menu synchronously, before
      the browser starts the next request).

ACCEPTANCE.md:
- Appends a "Phase 14 — Hamburger menu" section with the four
  manual scenarios from the plan:
    (a) admin dropdown contains Workers / Users / Audit log /
        Sign out in that order with an `<hr>` divider above Sign
        out;
    (b) non-admin dropdown contains only Sign out (no divider);
    (c) outside-click + Escape close the menu; Escape returns
        focus to the trigger;
    (d) Sign out from the menu posts with CSRF and logs out —
        one full loop, no JS-driven POST.
  Matches the existing imperative-checklist voice.

phpunit: expected 88 tests / 208 assertions, unchanged. View-only
plus client-side JS change, same pattern as Phases 10 and 13.

Smoke tests:
- php -l views/layout.php, node --check public/assets/js/app.js,
  and vendor/bin/phpunit were not runnable in the sandbox that
  executed this change (the harness blocks `php` and `node
  --check` invocations). Manual review confirmed the PHP template
  is structurally identical to the prior shape (admin gate, CSRF
  plumbing, e() escaping) and the new JS block uses only stable
  DOM APIs (`hidden` property, `closest()`, `focus()`). The user's
  manual acceptance step in a running container is the
  authoritative check — see ACCEPTANCE.md "Phase 14 — Hamburger
  menu".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 주 전
부모
커밋
101cc57e2c
3개의 변경된 파일125개의 추가작업 그리고 9개의 파일을 삭제
  1. 46 0
      ACCEPTANCE.md
  2. 43 0
      public/assets/js/app.js
  3. 36 9
      views/layout.php

+ 46 - 0
ACCEPTANCE.md

@@ -171,3 +171,49 @@ should fire (verify with the Network panel open).
      - The **Reset** button itself disappears.
      - Reload the page — nothing returns (all five localStorage keys
        cleared/reset to empty state).
+
+## Phase 14 — Hamburger menu
+
+Runs with any signed-in user. The header primary links (Sprints,
+New sprint) and the user badge stay inline; Workers / Users / Audit
+log / Sign out live behind the new hamburger button on the right.
+
+1. **Signed-in admin: dropdown contains Workers / Users / Audit log /
+   Sign out.**
+   - Sign in as an admin (see Setup).
+   - Click the hamburger button on the right of the header.
+   - Expected: panel opens immediately below the button with four
+     rows in order: **Workers**, **Users**, **Audit log**, a thin
+     `<hr>` divider, **Sign out**. `aria-expanded="true"` on the
+     trigger. Each row has `role="menuitem"`; the panel has
+     `role="menu"`. Clicking **Workers** navigates to `/workers`
+     and closes the menu.
+
+2. **Signed-in non-admin: dropdown contains only Sign out.**
+   - Sign in as a non-admin user (demote yourself from `/users`
+     while logged in as a second admin, or seed a user with
+     `is_admin=0` directly).
+   - Click the hamburger.
+   - Expected: panel contains a single **Sign out** row — no
+     Workers / Users / Audit log, no `<hr>` divider. The primary
+     **New sprint** link is already hidden for non-admins (admin
+     gate lives on the anchor itself).
+
+3. **Outside-click and Escape close the menu; focus returns to
+   trigger on Escape.**
+   - Open the menu (hamburger click).
+   - Click anywhere in the page outside the panel (e.g. the
+     Sprint Planner wordmark or the main content). Expected:
+     panel closes, `aria-expanded="false"`.
+   - Re-open the menu. Press **Escape**. Expected: panel closes
+     AND the hamburger button regains keyboard focus (visible via
+     the focus ring).
+
+4. **Sign out from the menu still posts with CSRF and logs out.**
+   - Open the menu → click **Sign out**.
+   - Expected: browser POSTs to `/auth/logout` (Network panel
+     shows a 302 with `_csrf` in the form payload), session is
+     cleared, and the page redirects to `/auth/login` (or the
+     public home with a "Sign in" CTA). `audit_log` has a new
+     `LOGOUT user` row. No JS-driven POST — the native `<form>`
+     carries the `_csrf` hidden input and submits the usual way.

+ 43 - 0
public/assets/js/app.js

@@ -34,3 +34,46 @@
         }
     });
 })(jQuery);
+
+/**
+ * Header hamburger menu. Vanilla JS — no jQuery. Toggles #app-menu's
+ * [hidden] + aria-expanded on the trigger, closes on outside-click,
+ * Escape (returning focus to the trigger), and on any menuitem click
+ * (so following a link / submitting the sign-out form feels snappy).
+ *
+ * Pattern mirrors the owner-filter / columns dropdowns in
+ * sprint-planner.js, minus jQuery.
+ */
+(function () {
+    'use strict';
+    const trigger = document.querySelector('[data-menu-trigger]');
+    const menu    = document.querySelector('[data-menu]');
+    if (!trigger || !menu) { return; }
+
+    function setOpen(open) {
+        menu.hidden = !open;
+        trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
+    }
+
+    trigger.addEventListener('click', function (ev) {
+        ev.stopPropagation();
+        setOpen(menu.hidden);
+    });
+
+    document.addEventListener('click', function (ev) {
+        if (menu.hidden) { return; }
+        if (ev.target === trigger || trigger.contains(ev.target)) { return; }
+        if (!menu.contains(ev.target)) { setOpen(false); }
+    });
+
+    document.addEventListener('keydown', function (ev) {
+        if (ev.key === 'Escape' && !menu.hidden) {
+            setOpen(false);
+            trigger.focus();
+        }
+    });
+
+    menu.addEventListener('click', function (ev) {
+        if (ev.target.closest('[role="menuitem"]')) { setOpen(false); }
+    });
+})();

+ 36 - 9
views/layout.php

@@ -29,10 +29,7 @@ $csrfToken   = $csrfToken   ?? '';
                 <?php if ($currentUser !== null): ?>
                     <a href="/" class="text-slate-600 hover:text-slate-900 hover:underline">Sprints</a>
                     <?php if ($currentUser->isAdmin): ?>
-                        <a href="/workers" class="text-slate-600 hover:text-slate-900 hover:underline">Workers</a>
-                        <a href="/users"   class="text-slate-600 hover:text-slate-900 hover:underline">Users</a>
                         <a href="/sprints/new" class="text-slate-600 hover:text-slate-900 hover:underline">New sprint</a>
-                        <a href="/audit" class="text-slate-600 hover:text-slate-900 hover:underline">Audit log</a>
                     <?php endif; ?>
                     <span class="text-slate-400">·</span>
                     <span class="text-slate-600">
@@ -41,13 +38,43 @@ $csrfToken   = $csrfToken   ?? '';
                             <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 rounded">admin</span>
                         <?php endif; ?>
                     </span>
-                    <form method="post" action="/auth/logout" class="inline">
-                        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
-                        <button type="submit"
-                                class="text-slate-600 hover:text-slate-900 hover:underline">
-                            Sign out
+                    <div class="relative">
+                        <button type="button"
+                                data-menu-trigger
+                                aria-expanded="false"
+                                aria-haspopup="true"
+                                aria-controls="app-menu"
+                                aria-label="Open menu"
+                                class="p-2 rounded-md hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" class="block stroke-current" fill="none" stroke-width="2" stroke-linecap="round">
+                                <line x1="3" y1="5"  x2="17" y2="5"></line>
+                                <line x1="3" y1="10" x2="17" y2="10"></line>
+                                <line x1="3" y1="15" x2="17" y2="15"></line>
+                            </svg>
                         </button>
-                    </form>
+                        <div id="app-menu"
+                             data-menu
+                             role="menu"
+                             hidden
+                             class="absolute right-0 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white shadow-lg py-1 z-10">
+                            <?php if ($currentUser->isAdmin): ?>
+                                <a href="/workers" role="menuitem"
+                                   class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">Workers</a>
+                                <a href="/users" role="menuitem"
+                                   class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">Users</a>
+                                <a href="/audit" role="menuitem"
+                                   class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">Audit log</a>
+                                <hr class="my-1 border-slate-200">
+                            <?php endif; ?>
+                            <form method="post" action="/auth/logout">
+                                <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                                <button type="submit" role="menuitem"
+                                        class="block w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 font-[inherit] focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                    Sign out
+                                </button>
+                            </form>
+                        </div>
+                    </div>
                 <?php else: ?>
                     <a href="/auth/login"
                        class="text-blue-700 hover:underline">Sign in</a>