feat select the exercise with a dropdown

Replace the vertical exercise button group with a dropdown. The selector is a GET form that submits the chosen slot to the workout path, so it works without script through the same ?slot= contract the panel already reads. The client enhances it: a change navigates immediately with a workout-scoped content swap, and the submit button is the no-JS fallback. A recorded slot is marked done in its option label.

Commit
75c67aceb14a006b1a84349ecedeafcc87efd4fa
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ENHANCEMENTS.org
index 81bda557..8e42408d 100644..100644
@@ -44,7 +44,7 @@
44 44 Remove the thick black bars visible in the mobile bottom navigation.
45 45 ** DONE Add a sticky workout timer bar
46 46 When logging a workout, show a sticky bar at the top of the screen with a workout timer.
47 Removed: ** TODO Replace the exercise button group with a dropdown
47 Added: ** DONE Replace the exercise button group with a dropdown
48 48 Use a dropdown menu to select the exercise instead of the exercise button group.
49 49 ** TODO Remove the boulder top border from exercise logging cards
50 50 Do not show a boulder top fieldset border on the exercise logging fieldset card.
@@ -56,3 +56,5 @@
56 56 Animate page transitions between Home, Current Workout, and Logbook.
57 57 ** TODO Confirm workout cancellation in a modal
58 58 Show a modal menu that confirms workout logging cancellation before cancelling.
59 Added: ** TODO Make workout logging fields compact on mobile
60 Added: Use a grid layout so the exercise name, load input, and reps input appear on one row on mobile.
lib/web/assets/hito.css
index 14886d4b..a77b7cc7 100644..100644
@@ -311,67 +311,25 @@
311 311 .in-progress-card h1 { margin: 0.35rem 0; }
312 312 .in-progress-card p { margin: 0; }
313 313
314 Removed: /* The exercise button group. Server-rendered links, one per prescription slot,
315 Removed: carrying the slot in a query parameter. The active button reads as pressed; a
316 Removed: recorded slot is marked with a check. Buttons stack vertically and fill the
317 Removed: width, so a narrow screen never needs horizontal scrolling. */
318 Removed: .exercise-group {
314 Added: /* The exercise selector. A dropdown naming every prescription slot, in a GET
315 Added: form that submits to the workout path. The client navigates on change, so the
316 Added: submit button is a no-JS fallback. Select and button share one row and wrap
317 Added: on a narrow screen. */
318 Added: .exercise-select {
319 319 display: flex;
320 Removed: flex-direction: column;
321 Removed: gap: 0.4rem;
320 Added: flex-wrap: wrap;
321 Added: align-items: end;
322 Added: gap: 0.6rem;
322 323 margin: 1.25rem 0 0;
323 324 }
324 Removed:
325 Removed: .exercise-button {
326 Removed: display: flex;
327 Removed: align-items: center;
328 Removed: gap: 0.6rem;
329 Removed: width: 100%;
330 Removed: min-height: 2.75rem;
331 Removed: padding: 0.6rem 0.85rem;
332 Removed: border: 1px solid var(--rule-strong);
333 Removed: border-radius: var(--radius);
334 Removed: color: var(--ink);
335 Removed: background: var(--paper);
336 Removed: font-size: 0.95rem;
337 Removed: font-weight: 600;
338 Removed: text-align: left;
339 Removed: text-decoration: none;
340 Removed: }
341 Removed:
342 Removed: .exercise-button:hover {
343 Removed: color: var(--oxblood-dark);
344 Removed: background: #ece4d5;
345 Removed: }
346 Removed:
347 Removed: .exercise-button.is-active {
348 Removed: color: var(--paper-raised);
349 Removed: background: var(--oxblood);
350 Removed: border-color: var(--oxblood-dark);
351 Removed: }
352 Removed:
353 Removed: .exercise-button-index {
354 Removed: display: grid;
325 Added: .exercise-select label { width: 100%; margin: 0 0 0.35rem; }
326 Added: .exercise-select select { flex: 1 1 12rem; min-width: 0; margin: 0; }
327 Added: .exercise-select .exercise-select-go {
355 328 flex: none;
356 Removed: width: 1.5rem;
357 Removed: height: 1.5rem;
358 Removed: place-items: center;
359 Removed: border: 1px solid currentColor;
360 Removed: border-radius: 50%;
361 Removed: font: 700 0.72rem/1 var(--mono);
362 Removed: font-variant-numeric: tabular-nums;
363 Removed: }
364 Removed:
365 Removed: .exercise-button.is-done .exercise-button-index {
366 Removed: border-color: var(--evergreen);
367 Removed: color: var(--paper-raised);
368 Removed: background: var(--evergreen);
369 Removed: }
370 Removed:
371 Removed: .exercise-button.is-active.is-done .exercise-button-index {
372 Removed: border-color: var(--paper-raised);
373 Removed: color: var(--oxblood);
374 Removed: background: var(--paper-raised);
329 Added: width: auto;
330 Added: min-height: 3rem;
331 Added: margin: 0;
332 Added: padding: 0.65rem 1.1rem;
375 333 }
376 334
377 335 /* The sticky workout timer bar. It pins to the top of the scroll area while a
lib/web/pages.ml
index b1ee5e99..fcecc5d8 100644..100644
@@ -631,18 +631,6 @@
631 631 Printf.sprintf "%s + %s" (Exercise.name isolation)
632 632 (Exercise.name compound)
633 633
634 Removed: (* The base path a slot's button links to, without the [?slot=] the exercise
635 Removed: group appends. Current-workout buttons return to [/workout]; a saved record's
636 Removed: buttons return to [/logbook/<id>]. The URL is built directly so the [slot]
637 Removed: query can be appended. *)
638 Removed: let exercise_href ~record_id slot =
639 Removed: let base =
640 Removed: match record_id with
641 Removed: | None -> "/workout"
642 Removed: | Some record_id -> "/logbook/" ^ record_id
643 Removed: in
644 Removed: Printf.sprintf "%s?slot=%d" base slot
645 Removed:
646 634 let workout request ~trainee ?(errors = []) ?editing ?logging ~record_id
647 635 ~active_slot workout =
648 636 let enhanced = Option.is_none record_id in
@@ -683,55 +671,58 @@
683 671 in
684 672 let recorded_at slot = List.assoc_opt slot filled in
685 673 let is_recorded slot = Option.is_some (recorded_at slot) in
686 Removed: (* The exercise button group: one full-width button per prescription slot, a
687 Removed: server-rendered link carrying the slot as a query parameter. The active
688 Removed: button opens the panel below; a recorded slot is marked done. This is link
689 Removed: navigation, not an ARIA widget: a labelled [nav] with [aria-current="page"]
690 Removed: on the active link is the accessible, deterministic pattern — no client
691 Removed: script, no focus state to manage. Buttons stack vertically, so a narrow
692 Removed: screen never needs horizontal scrolling. *)
674 Added: (* The exercise selector: a dropdown naming every prescription slot. Choosing
675 Added: an option opens that exercise below. The selector is a GET form so it works
676 Added: without script — submitting navigates to the slot via a [?slot=] query, the
677 Added: same contract the panel already reads. The client enhances it: it navigates
678 Added: the moment the selection changes, so the submit button is a no-JS fallback.
679 Added: A recorded slot is marked done in its label. *)
680 Added: let select_action_base =
681 Added: match record_id with None -> "/workout" | Some id -> "/logbook/" ^ id
682 Added: in
693 683 let exercise_group =
694 Removed: tag "nav"
684 Added: let option_for slot p =
685 Added: let done_ = is_recorded slot in
686 Added: let label =
687 Added: if done_ then
688 Added: Printf.sprintf "%d. %s (done)" (slot + 1) (exercise_label p)
689 Added: else Printf.sprintf "%d. %s" (slot + 1) (exercise_label p)
690 Added: in
691 Added: let attrs = [ Dream_html.string_attr "value" "%d" slot ] in
692 Added: let attrs =
693 Added: if slot = active_slot then Dream_html.attr "selected" :: attrs
694 Added: else attrs
695 Added: in
696 Added: tag "option" attrs [ txt "%s" label ]
697 Added: in
698 Added: let form_attrs =
695 699 [
696 Removed: class_ "exercise-group"; Dream_html.string_attr "aria-label" "Exercises";
700 Added: Dream_html.string_attr "method" "get";
701 Added: Dream_html.string_attr "action" "%s" select_action_base;
702 Added: class_ "exercise-select";
697 703 ]
698 Removed: (List.mapi
699 Removed: (fun slot p ->
700 Removed: let current = slot = active_slot in
701 Removed: let done_ = is_recorded slot in
702 Removed: let state =
703 Removed: match (current, done_) with
704 Removed: | true, true -> "exercise-button is-active is-done"
705 Removed: | true, false -> "exercise-button is-active"
706 Removed: | false, true -> "exercise-button is-done"
707 Removed: | false, false -> "exercise-button"
708 Removed: in
709 Removed: let attrs =
710 Removed: [
711 Removed: Dream_html.string_attr "class" "%s" state;
712 Removed: Dream_html.string_attr "href" "%s"
713 Removed: (exercise_href ~record_id slot);
714 Removed: ]
715 Removed: in
716 Removed: let attrs =
717 Removed: if enhanced then Dream_html.attr "data-hito-workout-link" :: attrs
718 Removed: else attrs
719 Removed: in
720 Removed: let attrs =
721 Removed: if current then
722 Removed: Dream_html.string_attr "aria-current" "page" :: attrs
723 Removed: else attrs
724 Removed: in
725 Removed: tag "a" attrs
726 Removed: [
727 Removed: tag "span"
728 Removed: [ class_ "exercise-button-index" ]
729 Removed: [ txt "%d" (slot + 1) ];
730 Removed: tag "span"
731 Removed: [ class_ "exercise-button-name" ]
732 Removed: [ txt "%s" (exercise_label p) ];
733 Removed: ])
734 Removed: prescribed_slots)
704 Added: in
705 Added: let form_attrs =
706 Added: if enhanced then Dream_html.attr "data-hito-exercise-form" :: form_attrs
707 Added: else form_attrs
708 Added: in
709 Added: tag "form" form_attrs
710 Added: [
711 Added: tag "label"
712 Added: [ Dream_html.string_attr "for" "exercise-choice" ]
713 Added: [ txt "Exercise" ];
714 Added: tag "select"
715 Added: [
716 Added: name "slot";
717 Added: id "exercise-choice";
718 Added: Dream_html.attr "data-hito-exercise-select";
719 Added: ]
720 Added: (List.mapi option_for prescribed_slots);
721 Added: void "input"
722 Added: [
723 Added: type_ "submit"; class_ "secondary exercise-select-go"; value "Open";
724 Added: ];
725 Added: ]
735 726 in
736 727 (* The panel for the active slot. It shows one exercise at a time: a recorded
737 728 slot renders its read-only summary and a pre-filled correction form (which
lib/web/workout_client.ml
index d6eb0422..50e5cca2 100644..100644
@@ -229,6 +229,30 @@
229 229 Lwt.async (fun () -> get (Js.to_string Dom_html.window##.location##.href));
230 230 Js._true
231 231
232 Added: (* The exercise dropdown navigates the moment its selection changes. It lives in
233 Added: a GET form ([data-hito-exercise-form]) that submits to the workout path; the
234 Added: client turns a change into a workout-scoped content swap to [?slot=N], so no
235 Added: submit click is needed. Without script the form's submit button still works. *)
236 Added: let exercise_change event =
237 Added: match closest "[data-hito-exercise-form]" (Dom_html.eventTarget event) with
238 Added: | None -> Js._true
239 Added: | Some form -> (
240 Added: let action =
241 Added: match Js.Opt.to_option (form##getAttribute (Js.string "action")) with
242 Added: | Some action -> Js.to_string action
243 Added: | None -> "/workout"
244 Added: in
245 Added: match query_one form "[data-hito-exercise-select]" with
246 Added: | None -> Js._true
247 Added: | Some node -> (
248 Added: match Js.Opt.to_option (Dom_html.CoerceTo.select node) with
249 Added: | None -> Js._true
250 Added: | Some select ->
251 Added: let slot = Js.to_string select##.value in
252 Added: Dom.preventDefault event;
253 Added: Lwt.async (fun () -> get ~workout:true (action ^ "?slot=" ^ slot));
254 Added: Js._false))
255 Added:
232 256 let () =
233 257 Dom_html.addEventListener document Dom_html.Event.click
234 258 (Dom_html.handler link_click)
@@ -236,6 +260,10 @@
236 260 |> ignore;
237 261 Dom_html.addEventListener document Dom_html.Event.submit
238 262 (Dom_html.handler form_submit)
263 Added: Js._false
264 Added: |> ignore;
265 Added: Dom_html.addEventListener document Dom_html.Event.change
266 Added: (Dom_html.handler exercise_change)
239 267 Js._false
240 268 |> ignore;
241 269 Dom_html.addEventListener Dom_html.window Dom_html.Event.popstate
test/test_web.ml
index 4db8e497..2bb77968 100644..100644
@@ -433,21 +433,24 @@
433 433 "renders Day 1 as the page heading" true
434 434 (contains ~substring:"<h1>Day 1</h1>" workout_page);
435 435 Alcotest.(check bool)
436 Removed: "renders a vertical exercise button group" true
437 Removed: (contains ~substring:"exercise-group" workout_page);
436 Added: "renders an exercise dropdown selector" true
437 Added: (contains ~substring:"exercise-select" workout_page);
438 438 Alcotest.(check bool)
439 439 "does not use the old tab strip class" false
440 440 (contains ~substring:"slot-tab" workout_page);
441 441 Alcotest.(check bool)
442 Removed: "labels the exercise group for assistive tech" true
443 Removed: (contains ~substring:"aria-label=\"Exercises\"" workout_page);
442 Added: "does not use the old button group class" false
443 Added: (contains ~substring:"exercise-group" workout_page);
444 444 Alcotest.(check bool)
445 Removed: "marks the active button with aria-current" true
446 Removed: (contains ~substring:"aria-current=\"page\"" workout_page);
445 Added: "marks the exercise select for enhancement" true
446 Added: (contains ~substring:"data-hito-exercise-select" workout_page);
447 447 Alcotest.(check bool)
448 Removed: "links a later slot by query parameter" true
449 Removed: (contains ~substring:"?slot=1" workout_page);
448 Added: "preselects the active slot option" true
449 Added: (contains ~substring:"selected" workout_page);
450 450 Alcotest.(check bool)
451 Added: "offers a later slot as a select option" true
452 Added: (contains ~substring:"value=\"1\"" workout_page);
453 Added: Alcotest.(check bool)
451 454 "opens on the first slot's record form" true
452 455 (contains ~substring:"/workout/slots/0" workout_page);
453 456 Alcotest.(check bool)
@@ -457,8 +460,8 @@
457 460 "marks the replaceable workout content" true
458 461 (contains ~substring:"data-hito-workout-content" workout_page);
459 462 Alcotest.(check bool)
460 Removed: "marks exercise links for enhancement" true
461 Removed: (contains ~substring:"data-hito-workout-link" workout_page);
463 Added: "marks the exercise form for enhancement" true
464 Added: (contains ~substring:"data-hito-exercise-form" workout_page);
462 465 Alcotest.(check bool)
463 466 "marks record forms while retaining their CSRF field" true
464 467 (contains ~substring:"data-hito-workout-form" workout_page
@@ -616,15 +619,14 @@
616 619 reached through the same [?slot=] state. *)
617 620 let record_page = body (get c "/logbook/w1?slot=1") in
618 621 Alcotest.(check bool)
619 Removed: "renders a vertical exercise button group for the saved workout"
620 Removed: true
621 Removed: (contains ~substring:"exercise-group" record_page);
622 Added: "renders an exercise dropdown for the saved workout" true
623 Added: (contains ~substring:"exercise-select" record_page);
622 624 Alcotest.(check bool)
623 625 "does not use the old tab strip class" false
624 626 (contains ~substring:"slot-tab" record_page);
625 627 Alcotest.(check bool)
626 Removed: "does not enhance saved workout links" false
627 Removed: (contains ~substring:"data-hito-workout-link" record_page);
628 Added: "does not enhance the saved exercise selector" false
629 Added: (contains ~substring:"data-hito-exercise-form" record_page);
628 630 Alcotest.(check bool)
629 631 "does not mark saved history as replaceable workout content" false
630 632 (contains ~substring:"data-hito-workout-content" record_page);