feat Make the profile control a drop-down

Replace the single profile link with a <details> menu that holds Profile, Settings, and Sign out. A <details> menu needs no client script, so it works before the SPA enhancement loads. The nested Sign-out form keeps its CSRF token, and the menu items keep aria-current and the SPA link marker. Settings points at /profile: account settings live there and there is no separate settings page. Add matching CSS with a clamped open animation, and extend the web tests to assert Profile, Settings, and Sign out are present. sim: application feedback (VPS, trainee t1, sequence 5)

Commit
8fc722308b1a0e6ddcfa5f3f7663bd596f2219d5
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/assets/hito.css
index c6cd2a5e..65eb3845 100644..100644
@@ -166,6 +166,71 @@
166 166 min-height: 2.75rem;
167 167 margin: 0;
168 168 padding: 0.5rem 0.9rem;
169 Added:
170 Added: /* The profile drop-down. The <summary> reuses the .profile pill styling; the
171 Added: panel opens below it with account actions. A <details> menu needs no script,
172 Added: so it works before the SPA enhancement loads. The open animation is clamped
173 Added: away for reduced-motion users by the global rule. */
174 Added: .profile-menu {
175 Added: position: relative;
176 Added: display: inline-block;
177 Added: }
178 Added: .profile-menu > summary {
179 Added: list-style: none;
180 Added: cursor: pointer;
181 Added: }
182 Added: .profile-menu > summary::-webkit-details-marker { display: none; }
183 Added: .profile-menu > summary::marker { content: ""; }
184 Added: .profile-menu-drawer {
185 Added: position: absolute;
186 Added: right: 0;
187 Added: z-index: 30;
188 Added: min-width: 11rem;
189 Added: margin-top: 0.35rem;
190 Added: padding: 0.3rem;
191 Added: display: flex;
192 Added: flex-direction: column;
193 Added: gap: 0.15rem;
194 Added: background: var(--paper-raised);
195 Added: border: 1px solid var(--rule-strong);
196 Added: border-radius: var(--radius);
197 Added: box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.18);
198 Added: }
199 Added: .profile-menu[open] > .profile-menu-drawer {
200 Added: animation: profile-menu-open 150ms ease both;
201 Added: }
202 Added: @keyframes profile-menu-open {
203 Added: from { opacity: 0; transform: translateY(-0.3rem); }
204 Added: to { opacity: 1; transform: translateY(0); }
205 Added: }
206 Added: .profile-menu-item {
207 Added: display: block;
208 Added: width: 100%;
209 Added: min-height: 2.5rem;
210 Added: box-sizing: border-box;
211 Added: padding: 0.5rem 0.7rem;
212 Added: border: none;
213 Added: border-radius: var(--radius);
214 Added: background: transparent;
215 Added: color: var(--ink);
216 Added: font: inherit;
217 Added: font-weight: 700;
218 Added: text-align: left;
219 Added: text-decoration: none;
220 Added: cursor: pointer;
221 Added: transition: background-color 150ms ease, color 150ms ease;
222 Added: }
223 Added: .profile-menu-item:hover {
224 Added: color: var(--oxblood-dark);
225 Added: background: #ece4d5;
226 Added: }
227 Added: .profile-menu-item[aria-current] {
228 Added: color: var(--paper-raised);
229 Added: background: var(--oxblood);
230 Added: }
231 Added: .profile-menu .logout {
232 Added: margin: 0;
233 Added: }
169 234 }
170 235
171 236 .page-home .bottom-nav > a[href="/"],
lib/web/pages.ml
index 836e9d95..479a3e14 100644..100644
@@ -51,21 +51,29 @@
51 51 nav_link "logbook" Routes.logbook "Logbook";
52 52 ]
53 53 in
54 Removed: (* The profile control names the signed-in trainee and opens the profile
55 Removed: page. It sits in the masthead, and on mobile it is the top-bar profile
56 Removed: button the bottom nav has no room for. *)
54 Added: (* The profile control names the signed-in trainee and opens a drop-down of
55 Added: account actions: Profile, Settings, and Sign out. It sits in the masthead,
56 Added: and on mobile it is the top-bar profile control the bottom nav has no room
57 Added: for. Settings points at the profile page too — account settings live there
58 Added: and there is no separate settings page. A <details> menu needs no client
59 Added: script, so it works before the SPA enhancement loads. The Sign-out form is
60 Added: nested inside and keeps its CSRF token. *)
57 61 let profile_area =
58 Removed: match trainee with
59 Removed: | Some (trainee : Trainee.t) ->
60 Removed: let profile_attrs =
61 Removed: [ class_ "profile"; href Routes.profile ]
62 Removed: @ if spa_client then [ Dream_html.attr "data-hito-app-link" ] else []
62 Added: match (trainee, request) with
63 Added: | Some (trainee : Trainee.t), Some request ->
64 Added: let menu_link page path label =
65 Added: let attrs = [ class_ "profile-menu-item"; href path ] in
66 Added: let attrs =
67 Added: if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
68 Added: else attrs
69 Added: in
70 Added: let attrs =
71 Added: if String.equal page active then
72 Added: Dream_html.string_attr "aria-current" "page" :: attrs
73 Added: else attrs
74 Added: in
75 Added: tag "a" attrs [ txt "%s" label ]
63 76 in
64 Removed: let profile_attrs =
65 Removed: if String.equal "profile" active then
66 Removed: Dream_html.string_attr "aria-current" "page" :: profile_attrs
67 Removed: else profile_attrs
68 Removed: in
69 77 let feedback_attrs =
70 78 [ class_ "app-feedback-button"; href Routes.app_feedback ]
71 79 @ if spa_client then [ Dream_html.attr "data-hito-app-link" ] else []
@@ -75,35 +83,45 @@
75 83 Dream_html.string_attr "aria-current" "page" :: feedback_attrs
76 84 else feedback_attrs
77 85 in
86 Added: let summary_attrs =
87 Added: if String.equal "profile" active then
88 Added: [ class_ "profile"; Dream_html.string_attr "aria-current" "page" ]
89 Added: else [ class_ "profile" ]
90 Added: in
78 91 [
79 92 tag "a" feedback_attrs [ txt "Feedback" ];
80 Removed: tag "a" profile_attrs
81 Removed: [ txt "%s" (Trainee.username_to_string trainee.username) ];
82 Removed: ]
83 Removed: | None -> []
84 Removed: in
85 Removed: let account_area =
86 Removed: match (trainee, request) with
87 Removed: | Some _, Some request ->
88 Removed: [
89 Removed: tag "div"
90 Removed: [ class_ "account" ]
93 Added: tag "details"
94 Added: [ class_ "profile-menu" ]
91 95 [
92 Removed: tag "form"
96 Added: tag "summary" summary_attrs
97 Added: [ txt "%s" (Trainee.username_to_string trainee.username) ];
98 Added: tag "div"
99 Added: [ class_ "profile-menu-drawer" ]
93 100 [
94 Removed: action Routes.logout;
95 Removed: post_form;
96 Removed: class_ "logout";
97 Removed: Dream_html.attr "data-hito-app-form";
98 Removed: ]
99 Removed: [
100 Removed: Dream_html.csrf_tag request;
101 Removed: void "input" [ type_ "submit"; value "Sign out" ];
101 Added: menu_link "profile" Routes.profile "Profile";
102 Added: menu_link "profile" Routes.profile "Settings";
103 Added: tag "form"
104 Added: [
105 Added: action Routes.logout;
106 Added: post_form;
107 Added: class_ "logout";
108 Added: Dream_html.attr "data-hito-app-form";
109 Added: ]
110 Added: [
111 Added: Dream_html.csrf_tag request;
112 Added: void "input"
113 Added: [
114 Added: type_ "submit";
115 Added: class_ "profile-menu-item";
116 Added: value "Sign out";
117 Added: ];
118 Added: ];
102 119 ];
103 120 ];
104 121 ]
105 122 | _ -> []
106 123 in
124 Added: let account_area = [] in
107 125 let primary_nav =
108 126 match (trainee, request) with
109 127 | Some (_ : Trainee.t), Some _ ->
test/test_web.ml
index 09f3cd67..7a1ac54a 100644..100644
@@ -257,12 +257,20 @@
257 257 "labels the logbook tab Logbook" true
258 258 (contains ~substring:">Logbook</a>" (body overview));
259 259 Alcotest.(check bool)
260 Removed: "shows the username on a profile control opening the profile page"
261 Removed: true
262 Removed: (contains ~substring:"class=\"profile\"" (body overview)
263 Removed: && contains ~substring:"href=\"/profile\"" (body overview)
264 Removed: && contains ~substring:">lifter</a>" (body overview));
260 Added: "shows the username on a profile drop-down control" true
261 Added: (contains ~substring:"class=\"profile-menu\"" (body overview)
262 Added: && contains ~substring:"<summary class=\"profile\""
263 Added: (body overview)
264 Added: && contains ~substring:">lifter</summary>" (body overview));
265 265 Alcotest.(check bool)
266 Added: "the drop-down offers Profile and Settings, both to /profile" true
267 Added: (contains ~substring:">Profile</a>" (body overview)
268 Added: && contains ~substring:">Settings</a>" (body overview)
269 Added: && contains ~substring:"href=\"/profile\"" (body overview));
270 Added: Alcotest.(check bool)
271 Added: "the drop-down offers Sign out" true
272 Added: (contains ~substring:"value=\"Sign out\"" (body overview));
273 Added: Alcotest.(check bool)
266 274 "omits the header username" false
267 275 (contains ~substring:"account-username" (body overview));
268 276 Alcotest.(check bool)
@@ -283,15 +291,16 @@
283 291 let c = client () in
284 292 let _ = sign_in_new c in
285 293 let page = body (get c "/") in
286 Removed: (* Inline affordances: the primary nav and the header sign-out form
287 Removed: both exist in the markup, ready for responsive CSS to reveal or
288 Removed: hide them. *)
294 Added: (* Inline affordances: the primary nav exists in the markup, and the
295 Added: sign-out form lives inside the profile drop-down, ready for
296 Added: responsive CSS to reveal or hide them. *)
289 297 Alcotest.(check bool)
290 298 "keeps the inline primary navigation" true
291 299 (contains ~substring:"class=\"primary-nav\"" page);
292 300 Alcotest.(check bool)
293 Removed: "keeps the header sign-out" true
294 Removed: (contains ~substring:"class=\"account\"" page);
301 Added: "keeps the sign-out inside the profile menu" true
302 Added: (contains ~substring:"class=\"profile-menu\"" page
303 Added: && contains ~substring:"value=\"Sign out\"" page);
295 304 (* No hamburger menu at any width: the disclosure and its classes
296 305 are gone. *)
297 306 Alcotest.(check bool)
@@ -309,13 +318,14 @@
309 318 let c = client () in
310 319 let _ = sign_in_new c in
311 320 let page = body (get c "/") in
312 Removed: (* Isolate the account area so the assertions read the sign-out
313 Removed: control rather than another form on the page. *)
321 Added: (* Isolate the profile-menu panel so the assertions read the
322 Added: sign-out control rather than another form on the page. *)
314 323 let account =
315 324 let start =
316 Removed: Option.get (index_from ~needle:"class=\"account\"" page 0)
325 Added: Option.get
326 Added: (index_from ~needle:"class=\"profile-menu-drawer\"" page 0)
317 327 in
318 Removed: let stop = Option.get (index_from ~needle:"</div>" page start) in
328 Added: let stop = Option.get (index_from ~needle:"</form>" page start) in
319 329 String.sub page start (stop - start)
320 330 in
321 331 Alcotest.(check bool)
@@ -613,12 +623,20 @@
613 623 in
614 624 Alcotest.(check int) "routine selected" 303 (status selection);
615 625 let page = body (get c "/routine") in
626 Added: (* The masthead profile menu is a <details>; scope the accordion
627 Added: check to the main content so it reads the routine, not the
628 Added: masthead. *)
629 Added: let content =
630 Added: match index_from ~needle:"id=\"main-content\"" page 0 with
631 Added: | Some start -> String.sub page start (String.length page - start)
632 Added: | None -> page
633 Added: in
616 634 Alcotest.(check bool)
617 635 "uses a table" true
618 636 (contains ~substring:"<table" page);
619 637 Alcotest.(check bool)
620 638 "no longer uses details accordions" false
621 Removed: (contains ~substring:"<details" page);
639 Added: (contains ~substring:"<details" content);
622 640 Alcotest.(check bool)
623 641 "heads the workout column" true
624 642 (contains ~substring:"Workout" page);
@@ -1762,7 +1780,7 @@
1762 1780 (contains ~substring:"Username changed" page);
1763 1781 Alcotest.(check bool)
1764 1782 "shows the new name in the profile control" true
1765 Removed: (contains ~substring:">athlete</a>" page) );
1783 Added: (contains ~substring:">athlete</summary>" page) );
1766 1784 ( "a taken username is refused with an inline error",
1767 1785 `Quick,
1768 1786 fun () ->