[OCaml] High Intensity Training Online
feat confirm workout cancellation in a modal
Cancelling now opens a native dialog that confirms the action. The client intercepts the cancel submit and shows the modal; 'Cancel workout' submits the form through the normal path and 'Keep logging' or Escape closes it. Without script the cancel form submits directly, so the confirm step is a progressive enhancement.
Changed files
ENHANCEMENTS.org
@@ -54,7 +54,13 @@
54
54
Change the WCAG keyboard-navigation focus halo color so it follows the general UI theme and is less visually obtrusive.
55
55
** DONE Animate page transitions
56
56
Animate page transitions between Home, Current Workout, and Logbook.
57
Removed:
** TODO Confirm workout cancellation in a modal
57
Added:
** DONE Confirm workout cancellation in a modal
58
58
Show a modal menu that confirms workout logging cancellation before cancelling.
59
59
** TODO Make workout logging fields compact on mobile
60
60
Use a grid layout so the exercise name, load input, and reps input appear on one row on mobile.
61
Added:
** TODO Allow spontaneous logbook feedback
62
Added:
Allow users to submit subjective feedback at any time from the Logbook. Suggest this feedback flow at the end of each workout, while keeping it available independently.
63
Added:
** TODO Style the cancellation modal to match the UI
64
Added:
Style the modal cancellation menu so it matches the overall UI aesthetic.
65
Added:
** TODO Preserve and navigate after modal workout cancellation
66
Added:
When cancellation is confirmed, return the Current Workout tab to the Routine tab, save the workout as a partial logbook entry, and keep it editable later.
lib/web/assets/hito.css
@@ -384,6 +384,42 @@
384
384
385
385
.slot-panel { margin-top: 1.25rem; }
386
386
387
Added:
/* The cancel confirmation modal. A native dialog centred on the viewport, with
388
Added:
a dimmed backdrop. Its actions reuse the button-group layout. It is shown by
389
Added:
the client; without script the cancel form submits directly. */
390
Added:
.confirm-dialog {
391
Added:
width: min(100%, 26rem);
392
Added:
margin: auto;
393
Added:
border: 1px solid var(--rule-strong);
394
Added:
border-top: 6px solid var(--oxblood);
395
Added:
border-radius: var(--radius);
396
Added:
background: var(--paper-raised);
397
Added:
box-shadow: 0 0.6rem 2rem rgba(26, 26, 22, 0.25);
398
Added:
padding: clamp(1.25rem, 3vw, 1.75rem);
399
Added:
color: var(--ink);
400
Added:
}
401
Added:
.confirm-dialog::backdrop {
402
Added:
background: rgba(26, 26, 22, 0.45);
403
Added:
}
404
Added:
.confirm-dialog h2 { margin-top: 0; }
405
Added:
.confirm-dialog button {
406
Added:
width: 100%;
407
Added:
min-height: 3rem;
408
Added:
border: 2px solid var(--oxblood-dark);
409
Added:
border-radius: var(--radius);
410
Added:
color: var(--paper-raised);
411
Added:
background: var(--oxblood);
412
Added:
cursor: pointer;
413
Added:
font: 700 var(--font-size-small)/1 var(--sans);
414
Added:
letter-spacing: 0.055em;
415
Added:
text-transform: uppercase;
416
Added:
}
417
Added:
.confirm-dialog button.secondary {
418
Added:
color: var(--brass);
419
Added:
background: transparent;
420
Added:
border-color: var(--brass);
421
Added:
}
422
Added:
387
423
.recorded-slot { margin: 1rem 0; }
388
424
.recorded-slot .done { margin-bottom: 0.35rem; }
389
425
lib/web/pages.ml
@@ -817,18 +817,58 @@
817
817
Dream_html.csrf_tag request;
818
818
void "input" [ type_ "submit"; value "Finish workout" ];
819
819
];
820
Added:
(* Cancel is a real POST form. Without script, its submit cancels
821
Added:
immediately. The client enhances it: it intercepts the submit,
822
Added:
opens the confirm dialog, and only submits the form when the
823
Added:
user confirms. *)
820
824
tag "form"
821
825
[
822
826
action Routes.cancel_workout;
823
827
post_form;
824
828
Dream_html.attr "data-hito-app-form";
829
Added:
Dream_html.attr "data-hito-confirm-form";
825
830
]
826
831
[
827
832
Dream_html.csrf_tag request;
828
833
void "input"
829
834
[
830
Removed:
type_ "submit"; class_ "secondary"; value "Cancel logging";
835
Added:
type_ "submit";
836
Added:
class_ "secondary";
837
Added:
value "Cancel logging";
838
Added:
Dream_html.attr "data-hito-confirm-cancel";
831
839
];
840
Added:
];
841
Added:
];
842
Added:
(* The confirmation modal. Native dialog for built-in focus trapping
843
Added:
and Escape-to-close. The client opens it; "Cancel workout" submits
844
Added:
the cancel form, "Keep logging" closes it. *)
845
Added:
tag "dialog"
846
Added:
[
847
Added:
class_ "confirm-dialog"; Dream_html.attr "data-hito-confirm-modal";
848
Added:
]
849
Added:
[
850
Added:
tag "h2" [] [ txt "Cancel this workout?" ];
851
Added:
tag "p" []
852
Added:
[
853
Added:
txt
854
Added:
"The workout is saved as-is for later editing, then you \
855
Added:
return Home.";
856
Added:
];
857
Added:
tag "div"
858
Added:
[ class_ "button-group" ]
859
Added:
[
860
Added:
tag "button"
861
Added:
[
862
Added:
type_ "button";
863
Added:
class_ "secondary";
864
Added:
Dream_html.attr "data-hito-confirm-dismiss";
865
Added:
]
866
Added:
[ txt "Keep logging" ];
867
Added:
tag "button"
868
Added:
[
869
Added:
type_ "button"; Dream_html.attr "data-hito-confirm-accept";
870
Added:
]
871
Added:
[ txt "Cancel workout" ];
832
872
];
833
873
];
834
874
]
lib/web/workout_client.ml
@@ -229,6 +229,55 @@
229
229
Lwt.async (fun () -> get (Js.to_string Dom_html.window##.location##.href));
230
230
Js._true
231
231
232
Added:
(* The cancel confirmation modal. The cancel form submits directly without
233
Added:
script; with script, its submit is intercepted to open a native dialog.
234
Added:
"Cancel workout" submits the form through the normal app-form path; "Keep
235
Added:
logging" and Escape close the dialog. The dialog and the pending form are
236
Added:
found fresh on each click, so a content swap never leaves a stale
237
Added:
reference. *)
238
Added:
let confirm_modal () = query_one document "[data-hito-confirm-modal]"
239
Added:
240
Added:
let close_modal () =
241
Added:
match confirm_modal () with
242
Added:
| None -> ()
243
Added:
| Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||])
244
Added:
245
Added:
let open_modal () =
246
Added:
match confirm_modal () with
247
Added:
| None -> ()
248
Added:
| Some modal -> ignore (Js.Unsafe.meth_call modal "showModal" [||])
249
Added:
250
Added:
let pending_confirm_form () =
251
Added:
match query_one document "[data-hito-confirm-form]" with
252
Added:
| None -> None
253
Added:
| Some element -> Js.Opt.to_option (Dom_html.CoerceTo.form element)
254
Added:
255
Added:
let confirm_click event =
256
Added:
let target = Dom_html.eventTarget event in
257
Added:
match closest "[data-hito-confirm-cancel]" target with
258
Added:
| Some _ ->
259
Added:
(* Opening the modal replaces immediate cancellation. *)
260
Added:
Dom.preventDefault event;
261
Added:
open_modal ();
262
Added:
Js._false
263
Added:
| None -> (
264
Added:
match closest "[data-hito-confirm-dismiss]" target with
265
Added:
| Some _ ->
266
Added:
Dom.preventDefault event;
267
Added:
close_modal ();
268
Added:
Js._false
269
Added:
| None -> (
270
Added:
match closest "[data-hito-confirm-accept]" target with
271
Added:
| Some _ -> (
272
Added:
Dom.preventDefault event;
273
Added:
close_modal ();
274
Added:
match pending_confirm_form () with
275
Added:
| Some form ->
276
Added:
ignore (Js.Unsafe.meth_call form "requestSubmit" [||]);
277
Added:
Js._false
278
Added:
| None -> Js._false)
279
Added:
| None -> Js._true))
280
Added:
232
281
(* The exercise dropdown navigates the moment its selection changes. It lives in
233
282
a GET form ([data-hito-exercise-form]) that submits to the workout path; the
234
283
client turns a change into a workout-scoped content swap to [?slot=N], so no
@@ -264,6 +313,10 @@
264
313
|> ignore;
265
314
Dom_html.addEventListener document Dom_html.Event.change
266
315
(Dom_html.handler exercise_change)
316
Added:
Js._false
317
Added:
|> ignore;
318
Added:
Dom_html.addEventListener document Dom_html.Event.click
319
Added:
(Dom_html.handler confirm_click)
267
320
Js._false
268
321
|> ignore;
269
322
Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate
test/test_web.ml
@@ -773,7 +773,13 @@
773
773
(contains ~substring:"Cancel logging" page);
774
774
Alcotest.(check bool)
775
775
"renders it as a secondary submit" true
776
Removed:
(contains ~substring:"class=\"secondary\"" page) );
776
Added:
(contains ~substring:"class=\"secondary\"" page);
777
Added:
Alcotest.(check bool)
778
Added:
"carries a confirmation modal for cancellation" true
779
Added:
(contains ~substring:"data-hito-confirm-modal" page);
780
Added:
Alcotest.(check bool)
781
Added:
"marks the cancel form for confirmation" true
782
Added:
(contains ~substring:"data-hito-confirm-form" page) );
777
783
( "the current workout shows a sticky timer bar",
778
784
`Quick,
779
785
fun () ->