diff --git a/core/templates/core/report.html b/core/templates/core/report.html index d12b0f9..e4fa618 100644 --- a/core/templates/core/report.html +++ b/core/templates/core/report.html @@ -61,14 +61,24 @@ Custom Dates + {# --- Month-mode pickers --- #} + {# "To" is optional: leave it blank for a single-month report. #} + {# If blank on OK, the JS submits `to_month = from_month` so the #} + {# report window collapses to just that month. Reduces the common #} + {# "I want only this month" flow from two picks to one. #}
- +
- + +
+ Leave blank for a single month +
@@ -157,22 +167,14 @@
- {# --- Apply / Cancel buttons (shown only when dirty) --- #} - + {# No global Apply button — each popover's OK commits + reloads directly. #} -{# --- Toast container (for cross-filter auto-removal notices) --- #} -
- {# --- Cross-filter data for the JS module --- #} {{ project_team_pairs_json|json_script:"projectTeamPairs" }} -{# --- Expose current URL filter state for JS reset + dirty diffing --- #} +{# --- Expose current URL filter state so the cross-filter can disable #} +{# dropdown options that are invalid given the OTHER pill's selection. #} {{ selected_project_ids|json_script:"urlSelectedProjectIds" }} {{ selected_team_ids|json_script:"urlSelectedTeamIds" }} @@ -544,25 +546,34 @@ {% endif %} {# === INLINE FILTERS — PILL POPOVER MODULE === #} -{# Scoped IIFE; runs once on DOMContentLoaded. Manages popover open/close, #} -{# dirty state, cross-filter, Apply submission. XSS-safe: createElement + #} -{# textContent only (no innerHTML with user data). Matches CLAUDE.md pattern. #} +{# Scoped IIFE; runs once on DOMContentLoaded. #} +{# #} +{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #} +{# (keeping other filters intact) and navigates → full SSR page reload. #} +{# Cancel just closes the popover. No "dirty" state, no global Apply. #} +{# #} +{# XSS-safe: textContent only; we never write user strings via innerHTML. #} {% if user.is_staff or user.is_superuser %} {% endif %} diff --git a/static/css/custom.css b/static/css/custom.css index 71f3840..ff72085 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1737,12 +1737,16 @@ body, .card, .modal-content, .form-control, .form-select, /* === Inline Filters (pill-as-dropdown) on the report page === */ /* Layered on top of the existing .filter-pill rules (lines ~1496–1524). - Five components: - 1. .filter-pill--editable: pointer cursor, hover tint, chevron - 2. .filter-pill--dirty: accent outline + small pulsing dot when uncommitted - 3. .filter-popover: absolute-positioned dropdown beneath the pill - 4. .apply-filters-group: slide-in Apply/Reset buttons when any pill dirty - 5. .filter-toast-container: cross-filter auto-removal notices + Three components: + 1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron + 2. .filter-popover: absolute-positioned dropdown anchored under the pill + 3. .filter-popover__footer: sticky bottom bar so the OK button stays + visible even when Choices.js expands its dropdown list over the body + + There is intentionally NO dirty-state indicator and NO global Apply button — + each popover's OK commits and reloads the page immediately. Simpler model, + less state to reason about. (Earlier revision had both; removed 2026-04-23 + after UX feedback.) */ /* --- Wrapper keeps the popover anchored to its pill --- */ @@ -1775,27 +1779,9 @@ body, .card, .modal-content, .form-control, .form-select, transform: rotate(180deg); } -/* --- Dirty state: pulsing accent dot + outline --- */ -.filter-pill--dirty { - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(232, 133, 26, 0.18); -} -.filter-pill--dirty::before { - content: ''; - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - margin-right: 0.4rem; - animation: filter-pill-pulse 1.4s ease-in-out infinite; -} -@keyframes filter-pill-pulse { - 0%, 100% { opacity: 0.55; } - 50% { opacity: 1; } -} - /* --- Popover positioned under the pill --- */ +/* max-height + flex column keeps the sticky footer visible even when the + popover body has to scroll (Choices.js can render a long list of options). */ .filter-popover { position: absolute; top: calc(100% + 6px); @@ -1803,18 +1789,26 @@ body, .card, .modal-content, .form-control, .form-select, z-index: 1040; /* below Bootstrap modal (1055) but above everything else */ min-width: 300px; max-width: 420px; + max-height: min(70vh, 520px); + display: flex; + flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-default); border-radius: 0.5rem; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); padding: 0; + overflow: hidden; /* clip Choices.js dropdown so the sticky footer wins */ } .filter-popover[hidden] { display: none; } .filter-popover__body { padding: 1rem; + overflow-y: auto; /* body scrolls when content exceeds max-height */ + flex: 1 1 auto; } +/* Footer is sticky at the bottom of the popover so the OK button is always + reachable — fixes the "Choices.js dropdown hides the OK button" complaint. */ .filter-popover__footer { display: flex; justify-content: flex-end; @@ -1823,6 +1817,10 @@ body, .card, .modal-content, .form-control, .form-select, border-top: 1px solid var(--border-default); background: var(--bg-inset); border-radius: 0 0 0.5rem 0.5rem; + flex: 0 0 auto; + position: sticky; + bottom: 0; + z-index: 2; } /* --- Mobile: popovers stretch full-width below the pill strip --- */ @@ -1835,56 +1833,8 @@ body, .card, .modal-content, .form-control, .form-select, right: 0; width: 100vw; max-width: 100vw; + max-height: 80vh; border-radius: 0.5rem 0.5rem 0 0; z-index: 1050; } } - -/* --- Apply / Reset button group --- */ -.apply-filters-group { - display: flex; - align-items: center; - animation: apply-slide-in 180ms ease-out; -} -.apply-filters-group[hidden] { - display: none; -} -@keyframes apply-slide-in { - from { opacity: 0; transform: translateX(8px); } - to { opacity: 1; transform: translateX(0); } -} - -/* --- Toast container for cross-filter auto-remove notices --- */ -.filter-toast-container { - position: fixed; - top: 4.5rem; /* below topbar */ - right: 1rem; - z-index: 1060; - display: flex; - flex-direction: column; - gap: 0.5rem; - pointer-events: none; - max-width: 320px; -} -.filter-toast { - background: var(--bg-card); - color: var(--text-primary); - border: 1px solid var(--accent); - border-left: 4px solid var(--accent); - border-radius: 0.375rem; - padding: 0.75rem 1rem; - font-size: 0.875rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22); - animation: filter-toast-in 200ms ease-out; - pointer-events: auto; -} -.filter-toast--exiting { - animation: filter-toast-out 200ms ease-in forwards; -} -@keyframes filter-toast-in { - from { opacity: 0; transform: translateX(12px); } - to { opacity: 1; transform: translateX(0); } -} -@keyframes filter-toast-out { - to { opacity: 0; transform: translateX(12px); } -}