diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index d172c0d..ef03082 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -117,6 +117,12 @@ h1, h2, h3, h4, h5, h6 { display: block; } +/* Reused Pico token: default muted small text sits near the 4.5:1 WCAG AA edge on dark backgrounds; this gives a safer ~5.4-5.7:1. */ +.form-help { + color: var(--pico-form-element-placeholder-color); + display: block; +} + /* Site header: 3-col grid (brand | banner | user-nav); height clipped to 72px */ .site-header { padding-block: 0; @@ -323,28 +329,33 @@ h1, h2, h3, h4, h5, h6 { white-space: nowrap; } -/* additional_animals: CheckboxSelectMultiple rendered as pill toggles */ -#field_additional_animals ul { - list-style: none; - padding: 0; - margin: 0.25rem 0 0; +/* additional_animals: CheckboxSelectMultiple renders div-based markup (not ul/li), styled here as pill toggles. */ +#field_additional_animals #id_additional_animals { display: flex; flex-wrap: wrap; gap: 0.375rem; + margin: 0.25rem 0 0; } -#field_additional_animals li { - display: block; -} - +/* Visually hidden (not display:none) so the checkbox keeps tab order and native Space-toggle behaviour. */ #field_additional_animals input[type="checkbox"] { - display: none; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -#field_additional_animals li label { +#field_additional_animals #id_additional_animals label { + position: relative; display: inline-flex; align-items: center; - padding: 0.3rem 0.75rem; + min-height: 44px; + padding: 0.4rem 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 20px; font-size: 0.875rem; @@ -355,17 +366,21 @@ h1, h2, h3, h4, h5, h6 { transition: background 0.15s, border-color 0.15s, color 0.15s; } -#field_additional_animals li label:hover { +#field_additional_animals #id_additional_animals label:hover { border-color: var(--pico-primary); } -#field_additional_animals li:has(input:checked) label, -#field_additional_animals label:has(input:checked) { +#field_additional_animals #id_additional_animals label:has(input:checked) { background: var(--pico-primary); border-color: var(--pico-primary); color: var(--pico-primary-inverse, #000); } +#field_additional_animals #id_additional_animals label:has(input:focus-visible) { + outline: 2px solid var(--pico-primary); + outline-offset: 2px; +} + /* note_tags: JS-enhanced pill tag input */ .tag-input-container { display: flex; diff --git a/static/js/modal.js b/static/js/modal.js index ee51b3e..73a2f91 100644 --- a/static/js/modal.js +++ b/static/js/modal.js @@ -8,6 +8,7 @@ if (!modal) return; var modalTitle = document.getElementById("modal-title"); + var modalBody = document.getElementById("modal-body"); // Populate modal title from the triggering element before the htmx request fires. document.addEventListener("htmx:beforeRequest", function (event) { @@ -18,13 +19,48 @@ } }); - // Show modal and re-init per-form JS after htmx injects content. + // Excludes disabled, tabindex="-1", and hidden (offsetParent-null) elements from being a focus target. + function isFocusable(el) { + if (!el) return false; + if (el.disabled) return false; + if (el.tabIndex < 0) return false; + if (el.offsetParent === null) return false; + return true; + } + + // Native defaults to focusing the dialog/article itself, not a form control — this picks a deterministic target instead. + function focusInitialElement() { + if (!modalBody) return; + + var explicit = modalBody.querySelector("[autofocus]"); + if (isFocusable(explicit)) { + explicit.focus(); + return; + } + + var candidates = modalBody.querySelectorAll( + "input:not([type=hidden]):not([type=checkbox]):not([type=radio])" + + ":not([type=submit]):not([type=button]):not([type=file]), select, textarea" + ); + for (var i = 0; i < candidates.length; i++) { + if (isFocusable(candidates[i])) { + candidates[i].focus(); + return; + } + } + + if (closeBtn) closeBtn.focus(); + } + + // Focus is chosen after initNoteForm() etc. so conditional-visibility scripts have already hidden/disabled fields. document.addEventListener("htmx:afterSwap", function (event) { if (event.detail.target.id !== "modal-body") return; if (!modal.open) modal.showModal(); if (typeof window.initNoteForm === "function") window.initNoteForm(); if (typeof window.initTagInput === "function") window.initTagInput(); if (typeof window.initAnimalSelect === "function") window.initAnimalSelect(); + // Deferred because showModal()'s async default-focus step would otherwise clobber a synchronous .focus() here. + requestAnimationFrame(focusInitialElement); }); // Close on backdrop click (clicking the element itself, not the article). diff --git a/templates/partials/form_fields.html b/templates/partials/form_fields.html index 7a27877..8fb2b2a 100644 --- a/templates/partials/form_fields.html +++ b/templates/partials/form_fields.html @@ -10,7 +10,7 @@ {{ field.label_tag }} {{ field }} {% if field.help_text %} - {{ field.help_text }} + {{ field.help_text }} {% endif %} {% for error in field.errors %} {{ error }}