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.

Commit
633923f19abd9905c0088a014df68efb4526b010
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ENHANCEMENTS.org
index 406bd5ce..1ad413e7 100644..100644
@@ -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
index b0174a93..87f53a01 100644..100644
@@ -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
index c7def261..0e00349b 100644..100644
@@ -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
index 50e5cca2..352702e9 100644..100644
@@ -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
index 5fe88dcc..7f4b0f83 100644..100644
@@ -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 () ->