← Back to task

Commit fc84d75d

commit fc84d75d38834417f8c9a27e7826a51a391644e5
Author: Ben Sima <ben@bensima.com>
Date:   Thu Nov 27 19:02:10 2025

    Ensure keyboard accessibility for status badge dropdown
    
    The implementation is complete. Here's a summary of the keyboard
    accessi
    
    **Changes to Omni/Jr/Web.hs:** 1. Added `statusDropdownJs` - JavaScript
    functions for keyboard handling
       - `toggleStatusDropdown()` - Opens/closes dropdown, updates
       aria-expa - `closeStatusDropdown()` - Closes dropdown, returns focus
       to trigger - `handleStatusKeydown()` - Handles Enter/Space (toggle),
       Escape (clo - `handleMenuItemKeydown()` - Handles ArrowUp/ArrowDown
       (navigate), E - Click-outside handler to close open dropdowns
    
    2. Updated `clickableBadge`:
       - Added `tabindex="0"` for keyboard focusability - Added
       `role="button"` for screen readers - Added `aria-haspopup="true"`
       and `aria-expanded="false"` - Changed onclick to use
       `toggleStatusDropdown()` function - Added `onkeydown` handler
    
    3. Updated `statusDropdownOptions`:
       - Added `role="menu"` and `aria-label` for accessibility
    
    4. Updated `statusOption`:
       - Added `role="none"` on form wrapper - Added `role="menuitem"`
       on button - Added `tabindex="-1"` (focus managed by JS) - Added
       `onkeydown` handler
    
    **Changes to Omni/Jr/Web/Style.hs:** - Added focus styles
    for `.status-dropdown-option:focus` - Added focus styles for
    `.status-badge-clickable:focus`
    
    Task-Id: t-157.3

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index c7e2e44b..9da31c90 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -187,6 +187,78 @@ pageHead title =
         Lucid.crossorigin_ "anonymous"
       ]
       ("" :: Text)
+    Lucid.script_ [] statusDropdownJs
+
+statusDropdownJs :: Text
+statusDropdownJs =
+  Text.unlines
+    [ "function toggleStatusDropdown(el) {",
+      "  var container = el.parentElement;",
+      "  var isOpen = container.classList.toggle('open');",
+      "  el.setAttribute('aria-expanded', isOpen);",
+      "  if (isOpen) {",
+      "    var firstItem = container.querySelector('[role=\"menuitem\"]');",
+      "    if (firstItem) firstItem.focus();",
+      "  }",
+      "}",
+      "",
+      "function closeStatusDropdown(container) {",
+      "  container.classList.remove('open');",
+      "  var badge = container.querySelector('[role=\"button\"]');",
+      "  if (badge) {",
+      "    badge.setAttribute('aria-expanded', 'false');",
+      "    badge.focus();",
+      "  }",
+      "}",
+      "",
+      "function handleStatusKeydown(event, el) {",
+      "  if (event.key === 'Enter' || event.key === ' ') {",
+      "    event.preventDefault();",
+      "    toggleStatusDropdown(el);",
+      "  } else if (event.key === 'Escape') {",
+      "    closeStatusDropdown(el.parentElement);",
+      "  } else if (event.key === 'ArrowDown') {",
+      "    event.preventDefault();",
+      "    var container = el.parentElement;",
+      "    if (!container.classList.contains('open')) {",
+      "      toggleStatusDropdown(el);",
+      "    } else {",
+      "      var firstItem = container.querySelector('[role=\"menuitem\"]');",
+      "      if (firstItem) firstItem.focus();",
+      "    }",
+      "  }",
+      "}",
+      "",
+      "function handleMenuItemKeydown(event) {",
+      "  var container = event.target.closest('.status-badge-dropdown');",
+      "  var items = container.querySelectorAll('[role=\"menuitem\"]');",
+      "  var currentIndex = Array.from(items).indexOf(event.target);",
+      "  ",
+      "  if (event.key === 'ArrowDown') {",
+      "    event.preventDefault();",
+      "    var next = (currentIndex + 1) % items.length;",
+      "    items[next].focus();",
+      "  } else if (event.key === 'ArrowUp') {",
+      "    event.preventDefault();",
+      "    var prev = (currentIndex - 1 + items.length) % items.length;",
+      "    items[prev].focus();",
+      "  } else if (event.key === 'Escape') {",
+      "    event.preventDefault();",
+      "    closeStatusDropdown(container);",
+      "  } else if (event.key === 'Tab') {",
+      "    closeStatusDropdown(container);",
+      "  }",
+      "}",
+      "",
+      "document.addEventListener('click', function(e) {",
+      "  var dropdowns = document.querySelectorAll('.status-badge-dropdown.open');",
+      "  dropdowns.forEach(function(d) {",
+      "    if (!d.contains(e.target)) {",
+      "      closeStatusDropdown(d);",
+      "    }",
+      "  });",
+      "});"
+    ]
 
 navbar :: (Monad m) => Lucid.HtmlT m ()
 navbar =
@@ -294,20 +366,30 @@ clickableBadge status _tid =
         TaskCore.Done -> ("badge badge-done status-badge-clickable", "Done")
    in Lucid.span_
         [ Lucid.class_ cls,
-          Lucid.makeAttribute "onclick" "this.parentElement.classList.toggle('open')"
+          Lucid.tabindex_ "0",
+          Lucid.role_ "button",
+          Lucid.makeAttribute "aria-haspopup" "true",
+          Lucid.makeAttribute "aria-expanded" "false",
+          Lucid.makeAttribute "onclick" "toggleStatusDropdown(this)",
+          Lucid.makeAttribute "onkeydown" "handleStatusKeydown(event, this)"
         ]
         <| do
           Lucid.toHtml label
-          Lucid.span_ [Lucid.class_ "dropdown-arrow"] " ▾"
+          Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
 
 statusDropdownOptions :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
 statusDropdownOptions currentStatus tid =
-  Lucid.div_ [Lucid.class_ "status-dropdown-menu"] <| do
-    statusOption TaskCore.Open currentStatus tid
-    statusOption TaskCore.InProgress currentStatus tid
-    statusOption TaskCore.Review currentStatus tid
-    statusOption TaskCore.Approved currentStatus tid
-    statusOption TaskCore.Done currentStatus tid
+  Lucid.div_
+    [ Lucid.class_ "status-dropdown-menu",
+      Lucid.role_ "menu",
+      Lucid.makeAttribute "aria-label" "Change task status"
+    ]
+    <| do
+      statusOption TaskCore.Open currentStatus tid
+      statusOption TaskCore.InProgress currentStatus tid
+      statusOption TaskCore.Review currentStatus tid
+      statusOption TaskCore.Approved currentStatus tid
+      statusOption TaskCore.Done currentStatus tid
 
 statusOption :: (Monad m) => TaskCore.Status -> TaskCore.Status -> Text -> Lucid.HtmlT m ()
 statusOption opt currentStatus tid =
@@ -321,6 +403,7 @@ statusOption opt currentStatus tid =
       optClass = cls <> " status-dropdown-option" <> if isSelected then " selected" else ""
    in Lucid.form_
         [ Lucid.class_ "status-option-form",
+          Lucid.role_ "none",
           Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/status"),
           Lucid.makeAttribute "hx-target" "#status-badge-container",
           Lucid.makeAttribute "hx-swap" "outerHTML"
@@ -329,7 +412,10 @@ statusOption opt currentStatus tid =
           Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "status", Lucid.value_ (tshow opt)]
           Lucid.button_
             [ Lucid.type_ "submit",
-              Lucid.class_ optClass
+              Lucid.class_ optClass,
+              Lucid.role_ "menuitem",
+              Lucid.tabindex_ "-1",
+              Lucid.makeAttribute "onkeydown" "handleMenuItemKeydown(event)"
             ]
             (Lucid.toHtml label)
 
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index 594fb21e..8ad239c7 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -558,9 +558,16 @@ statusBadges = do
     transition "opacity" (ms 150) ease (sec 0)
   ".status-dropdown-option" # hover ? do
     opacity 0.7
+  ".status-dropdown-option" # focus ? do
+    opacity 0.85
+    Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
+    Stylesheet.key "outline-offset" ("1px" :: Text)
   ".status-dropdown-option.selected" ? do
     Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
     Stylesheet.key "outline-offset" ("1px" :: Text)
+  ".status-badge-clickable" # focus ? do
+    Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
+    Stylesheet.key "outline-offset" ("2px" :: Text)
 
 buttonStyles :: Css
 buttonStyles = do