diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-27 19:02:10 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-27 19:02:10 -0500 |
| commit | fc84d75d38834417f8c9a27e7826a51a391644e5 (patch) | |
| tree | 7d1294279c41d72ecc64347fdfb85968ca7910f0 | |
| parent | ebc281f90389da3b064bb14888e7c4f81ae4df17 (diff) | |
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
| -rw-r--r-- | Omni/Jr/Web.hs | 104 | ||||
| -rw-r--r-- | Omni/Jr/Web/Style.hs | 7 |
2 files changed, 102 insertions, 9 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index c7e2e44..9da31c9 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 594fb21..8ad239c 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 |
