[OCaml] High Intensity Training Online
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)
Changed files
lib/web/assets/hito.css
@@ -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
@@ -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
@@ -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 () ->