diff --git a/web/static/css/style.css b/web/static/css/style.css index ec1901c..9648be4 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1,5 +1,18 @@ /* Favoritter — custom styles on top of Pico CSS */ +/* Visually hidden, accessible to screen readers */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Skip navigation link for accessibility */ .skip-link { position: absolute; @@ -143,9 +156,14 @@ } .tag-suggestion:hover, -.tag-suggestion:focus { +.tag-suggestion:focus-visible { + background: var(--pico-primary-focus); + outline: 2px solid var(--pico-primary); + outline-offset: -2px; +} + +.tag-suggestion[aria-selected="true"] { background: var(--pico-primary-focus); - outline: none; } /* Fave detail actions */ @@ -177,7 +195,11 @@ } .disabled-row { - opacity: 0.5; + color: var(--pico-muted-color); +} + +.disabled-row td:first-child { + text-decoration: line-through; } .inline-input { @@ -188,6 +210,11 @@ font-size: 0.875rem; } +/* Responsive admin tables */ +.table-responsive { + overflow-x: auto; +} + /* Respect reduced motion preference */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/web/static/js/app.js b/web/static/js/app.js index 8d2ad83..828dbf3 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -22,18 +22,42 @@ } }); - // Focus management after HTMX content swaps for accessibility. + // Update aria-expanded and announce suggestion count after HTMX swaps. document.body.addEventListener("htmx:afterSwap", function (event) { var target = event.detail.target; - if (target) { - var autoFocus = target.querySelector("[autofocus]"); - if (autoFocus) { - autoFocus.focus(); + if (!target) return; + + // Focus management: if swapped content has autofocus, focus it. + var autoFocus = target.querySelector("[autofocus]"); + if (autoFocus) { + autoFocus.focus(); + } + + // Tag suggestions: update combobox state. + if (target.id === "tag-suggestions") { + var input = document.getElementById("tags"); + var items = target.querySelectorAll("[role='option']"); + var count = items.length; + + if (input) { + input.setAttribute("aria-expanded", count > 0 ? "true" : "false"); } + + // Announce suggestion count to screen readers. + var status = document.getElementById("tag-status"); + if (status) { + status.textContent = count > 0 + ? count + " forslag tilgjengelig" + : ""; + } + + // Reset active descendant tracking. + activeIndex = -1; + clearActiveDescendant(); } }); - // After a successful HTMX DELETE, redirect if the element has a data-redirect attribute. + // After a successful HTMX DELETE, redirect if the element has a data-redirect. document.body.addEventListener("htmx:afterRequest", function (event) { if (event.detail.successful && event.detail.verb === "delete") { var redirect = event.detail.elt.getAttribute("data-redirect"); @@ -43,25 +67,97 @@ } }); - // Tag autocomplete: add a selected tag to the tag input. + // --- Tag autocomplete combobox pattern --- + var activeIndex = -1; + + // Handle keyboard navigation in the tag suggestions listbox. + document.addEventListener("keydown", function (event) { + var input = document.getElementById("tags"); + if (!input || document.activeElement !== input) return; + + var listbox = document.getElementById("tag-suggestions"); + if (!listbox) return; + var items = listbox.querySelectorAll("[role='option']"); + if (items.length === 0) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + activeIndex = Math.min(activeIndex + 1, items.length - 1); + setActiveDescendant(items); + break; + case "ArrowUp": + event.preventDefault(); + activeIndex = Math.max(activeIndex - 1, 0); + setActiveDescendant(items); + break; + case "Enter": + if (activeIndex >= 0 && activeIndex < items.length) { + event.preventDefault(); + var tagName = items[activeIndex].textContent.trim(); + addTag(null, tagName); + } + break; + case " ": + if (activeIndex >= 0 && activeIndex < items.length) { + event.preventDefault(); + var tagName2 = items[activeIndex].textContent.trim(); + addTag(null, tagName2); + } + break; + case "Escape": + closeSuggestions(); + break; + } + }); + + function setActiveDescendant(items) { + for (var i = 0; i < items.length; i++) { + items[i].setAttribute("aria-selected", i === activeIndex ? "true" : "false"); + } + var input = document.getElementById("tags"); + if (input && activeIndex >= 0) { + var activeItem = items[activeIndex]; + if (!activeItem.id) { + activeItem.id = "tag-option-" + activeIndex; + } + input.setAttribute("aria-activedescendant", activeItem.id); + activeItem.scrollIntoView({ block: "nearest" }); + } + } + + function clearActiveDescendant() { + var input = document.getElementById("tags"); + if (input) { + input.removeAttribute("aria-activedescendant"); + } + } + + function closeSuggestions() { + var listbox = document.getElementById("tag-suggestions"); + if (listbox) { + while (listbox.firstChild) { + listbox.removeChild(listbox.firstChild); + } + } + var input = document.getElementById("tags"); + if (input) { + input.setAttribute("aria-expanded", "false"); + } + activeIndex = -1; + } + + // Add a selected tag to the tag input. window.addTag = function (element, tagName) { var input = document.getElementById("tags"); if (!input) return; var parts = input.value.split(",").map(function (s) { return s.trim(); }); - // Replace the last (incomplete) segment with the selected tag. parts[parts.length - 1] = tagName; - // Add a trailing separator so the user can keep typing. input.value = parts.join(", ") + ", "; input.focus(); - // Clear suggestions by removing all child elements. - var suggestions = document.getElementById("tag-suggestions"); - if (suggestions) { - while (suggestions.firstChild) { - suggestions.removeChild(suggestions.firstChild); - } - } + closeSuggestions(); }; function getCookie(name) { diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index ac4c0df..c08d892 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -51,7 +51,7 @@

Drevet av Favoritter - — fri programvare under AGPL-3.0 + — fri programvare under AGPL-3.0

diff --git a/web/templates/pages/admin_dashboard.html b/web/templates/pages/admin_dashboard.html index 3acba2c..ad422be 100644 --- a/web/templates/pages/admin_dashboard.html +++ b/web/templates/pages/admin_dashboard.html @@ -25,7 +25,7 @@ -