doxa-maps/library/components/legends/LegendRows.vue
Doxa Maps c74e191dbd
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 835ec1ac
2026-09-26 22:25:05 +00:00

312 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="legend-row-group"
:class="isDark ? 'lrg-dark' : 'lrg-light'"
>
<!-- ONE grid table. First column is an invisible caret column — it doubles
as the card's left padding AND as the rendering column for every
row-level caret (title caret via #title-caret slot, child-expand
carets on tree rows). A matching trailing column on the right of
the same width provides symmetric right padding without the card
needing its own left/right padding. See documentation/legend-spec.md. -->
<div class="lrg-items" :style="{ '--lrg-grid-cols': gridCols }" ref="itemsContainer">
<!-- Title row — first row of the table. Caret slot in col 1, title in
col 2, column headers auto-placed into remaining tracks. -->
<div v-if="!hideColumnHeader && (title || columns.length)" class="lrg-title-row">
<span class="lrg-title-caret-slot"><slot name="title-caret" /></span>
<span class="lrg-title">{{ displayTitle }}</span>
<span v-for="col in columns" :key="col.key" class="lrg-header-col">{{ col.label }}</span>
<!-- Prayer progress bar — second line of the (sticky) header row, spanning
the pill columns. Rendered only when a progress payload exists (prayer
legend); the title above then reads "Progress". Clicking a span selects
the matching row through the SAME filter-click the rows emit. -->
<LegendProgressBar
v-if="progressData"
class="lrg-progress"
:covered="progressData.covered"
:total="progressData.total"
:segments="progressData.segments"
:is-dark="isDark"
:fmt-count="fmtCount"
@select-segment="key => $emit('filter-click', key)"
/>
</div>
<!-- Data rows — colored pills, clickable. The .lrg-item / .lrg-item-inner
wrappers are present for LegendMobile.vue CSS compatibility; on
desktop they are display:contents so the subgrid is flat. -->
<template v-for="item in items" :key="item.key">
<div class="lrg-row" :data-lrg-filter-key="item.filterKey">
<button
v-if="item.children && item.children.length && !disableCollapse"
class="lrg-caret"
:class="{ expanded: !collapsed[item.key] }"
@click.stop="toggleCollapse(item.key)"
aria-label="Toggle children"
>
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- Empty col-1 placeholder keeps subgrid flat for non-tree rows. -->
<span v-else class="lrg-caret-placeholder" aria-hidden="true"></span>
<div
class="lrg-item"
:class="{ selected: activeFilter === item.filterKey }"
@click="$emit('filter-click', item.filterKey)"
>
<div class="lrg-item-inner" :style="{ backgroundColor: item.color, '--lrg-item-color': item.color }">
<span class="lrg-name">{{ item.label }}</span>
<span v-for="col in columns" :key="col.key" class="lrg-badge">
<span class="lrg-badge-inner">
{{ col.key === 'population' ? fmtPop(item.population) : fmtCount(item.count) }}
</span>
</span>
</div>
</div>
</div>
<!-- Children rows -->
<template v-if="item.children && item.children.length && !collapsed[item.key]">
<div v-for="child in item.children" :key="child.key" class="lrg-row lrg-row-child" :data-lrg-filter-key="child.filterKey">
<span class="lrg-caret-placeholder" aria-hidden="true"></span>
<div
class="lrg-item"
:class="{ selected: activeFilter === child.filterKey }"
@click="$emit('filter-click', child.filterKey)"
>
<div class="lrg-item-inner" :style="{ backgroundColor: child.color, '--lrg-item-color': child.color }">
<span class="lrg-name">{{ child.label }}</span>
<span v-for="col in columns" :key="col.key" class="lrg-badge">
<span class="lrg-badge-inner">
{{ col.key === 'population' ? fmtPop(child.population) : fmtCount(child.count) }}
</span>
</span>
</div>
</div>
</div>
</template>
</template>
<!-- Footer totals (only when columns exist AND showFooter true).
doxa-simple-map passes showFooter=false. -->
<div v-if="columns.length && showFooter" class="lrg-footer">
<span class="lrg-footer-caret-slot" aria-hidden="true"></span>
<span class="lrg-footer-label">{{ t('legend.footer.total') }}</span>
<span v-for="col in columns" :key="col.key" class="lrg-footer-value">
{{ col.key === 'population' ? fmtPop(totalPopulation) : fmtCount(totalCount) }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, computed, ref, watch, nextTick, inject, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import LegendProgressBar from './LegendProgressBar.vue'
const { t } = useI18n()
useShadowStyles(`
/* ── LegendRows — single flat grid with an invisible caret column.
Spec: documentation/legend-spec.md. One parent grid (.lrg-items) with subgrid
children (.lrg-title-row, .lrg-row, .lrg-footer). Col 1 is the caret
column (doubles as the legend's left padding); last col is a matching
trailing track (right padding). Both are --lrg-caret-col wide so the
legend is visually symmetric without any outer left/right padding. ── */
.legend-row-group{--lrg-caret-col:30px;display:flex;flex-direction:column;height:100%;min-height:0;}
/* The grid + scroll container. Tracks:
[caret --lrg-caret-col] [name 1fr] [UPGS auto] [POPULATION auto] [trailing --lrg-caret-col].
Title row + every data row is a subgrid of these tracks.
align-content:start — CRITICAL: without this, grid auto-rows STRETCH to
fill flex:1 parent. Packing rows to the top keeps them at natural height. */
/* No top padding here — the sticky .lrg-title-row owns that space via its
own padding-top so its white/dark bg covers what would otherwise be a
transparent gutter that scrolled rows leak through (2026-04-27). */
.lrg-items{display:grid;grid-template-columns:var(--lrg-grid-cols, var(--lrg-caret-col) minmax(80px,1fr) auto auto var(--lrg-caret-col));grid-auto-rows:auto;align-content:start;column-gap:6px;row-gap:6px;padding:0 0 12px;flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-ms-overflow-style:none;}
.lrg-items::-webkit-scrollbar{display:none;width:0;height:0;}
/* Title row — first row of the table. Plain (not colored). Shares tracks.
Col 1 = title caret slot, col 2 = title text, cols 3+ = column headers.
position:sticky keeps it visible when the user scrolls long row lists
(qa.md frozen-header request 2026-04-27). */
/* padding-top:12px is INSIDE the sticky element so the white/dark bg
covers the gutter that would otherwise be transparent. line-height:1.2
on the title (not 1) so cap-letter ascenders / G aren't clipped. */
.lrg-title-row{display:grid;grid-template-columns:subgrid;grid-column:1 / -1;align-items:center;padding:12px 0 6px;position:sticky;top:0;z-index:5;background:#fff;}
.lrg-dark .lrg-title-row{background:#3b463d;}
.lrg-title-caret-slot{grid-column:1;align-self:center;display:flex;align-items:center;justify-content:center;line-height:1;}
.lrg-title{grid-column:2;align-self:center;font-weight:600;font-size:13px;line-height:1.2;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lrg-header-col{align-self:center;text-align:center;white-space:nowrap;font-size:10px;line-height:1.3;color:#888;text-transform:uppercase;letter-spacing:0.3px;font-weight:700;}
/* Prayer progress bar — 2nd implicit row of the title-row subgrid, spanning the
pill columns (2 / -2) so it lines up with the rows beneath. The subgrid has no
row-gap of its own, hence the margin. */
.lrg-progress{grid-column:2 / -2;margin-top:6px;}
/* Data row — subgrid of .lrg-items. Caret lives in col 1 (real grid cell,
not absolute); the pill spans cols 2 to -2 so the trailing column stays
empty as right padding. */
.lrg-row{display:grid;grid-template-columns:subgrid;grid-column:1 / -1;align-items:center;}
.lrg-row-child{margin-inline-start:0;}
/* .lrg-item and .lrg-item-inner are layout-transparent on desktop so the
subgrid stays flat. LegendMobile.vue overrides these at higher specificity
so mobile keeps its own block layout. */
.lrg-item{display:contents;cursor:pointer;}
.lrg-item-inner{display:grid;grid-template-columns:subgrid;grid-column:2 / -2;align-items:center;border-radius:6px;min-height:36px;padding:6px 0;transition:opacity 0.15s ease;}
.lrg-item-inner:hover{opacity:0.92;}
.lrg-item.selected .lrg-item-inner{outline:2px solid rgba(0,0,0,0.25);outline-offset:-2px;}
/* Caret — real grid cell in col 1, centered. Zero padding per spec: the
column width IS the padding, the caret just sits in the middle of it. */
.lrg-caret{grid-column:1;justify-self:center;align-self:center;width:10px;height:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#fff;background:none;border:none;padding:0;transition:transform 0.2s ease;outline:none;-webkit-tap-highlight-color:transparent;opacity:0.85;}
.lrg-caret:focus{outline:none;}
.lrg-caret.expanded{transform:rotate(90deg);}
/* Empty col-1 slot on non-tree rows — keeps subgrid flat without rendering
anything visible. */
.lrg-caret-placeholder{grid-column:1;display:block;}
/* Name — first pill column (col 2 of parent grid, col 1 of the subgrid). */
.lrg-name{grid-column:1;padding:0 0 0 8px;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* Badges — auto-placed into the remaining subgrid tracks. Centered in track.
Background darkens the row color by 28% (mix with #000) so white badge text
stays readable on bright rows like the engagement-tab "Engaged" green. The
previous mix used currentColor (which is white in light mode), so on bright
rows it LIGHTENED the badge — wrong direction. qa: 2026-05-02. */
.lrg-badge{display:flex;align-items:center;justify-content:center;}
.lrg-badge-inner{display:inline-block;background:color-mix(in srgb, var(--lrg-item-color, #888), #000 28%);border-radius:8px;padding:2px 10px;font-size:11px;font-weight:600;font-family:'Courier New',monospace;white-space:nowrap;min-width:32px;text-align:center;}
/* Footer — col 1 empty (matches caret column), col 2 label, trailing values. */
.lrg-footer{display:grid;grid-template-columns:subgrid;grid-column:1 / -1;align-items:center;padding:5px 0;border-top:1px solid rgba(0,0,0,0.07);font-size:11px;font-weight:600;color:#555;background:#fff;position:sticky;bottom:0;}
.lrg-footer-caret-slot{grid-column:1;}
.lrg-footer-label{grid-column:2;color:#777;}
.lrg-footer-value{text-align:center;font-family:'Courier New',monospace;}
/* ── Light mode ── text over colored pills is white. Badge text gets the
same shadow as the label so the count/pop digits stay readable on bright
backgrounds (engagement-tab "Engaged" green was unreadable without it). */
.lrg-light .lrg-name{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.35);}
.lrg-light .lrg-badge-inner{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.4);}
.lrg-light .lrg-caret{color:#666;}
/* ── Dark mode ── */
.lrg-dark .lrg-name{color:#F3F3F1;text-shadow:0 1px 2px rgba(0,0,0,0.4);}
.lrg-dark .lrg-badge-inner{color:#F3F3F1;text-shadow:0 1px 2px rgba(0,0,0,0.4);}
.lrg-dark .lrg-caret{color:rgba(243,243,241,0.75);}
.lrg-dark .lrg-title{color:#F3F3F1;}
.lrg-dark .lrg-header-col{color:rgba(243,243,241,0.65);}
.lrg-dark .lrg-footer{background:#2a332a;border-top-color:rgba(255,255,255,0.1);color:rgba(243,243,241,0.75);}
.lrg-dark .lrg-footer-label{color:rgba(243,243,241,0.65);}
`, 'legend-row-group')
const props = defineProps({
/** Table title — rendered as the name-column text of the first (header) row */
title: { type: String, default: '' },
/** Array of LegendItem: { key, label, color, count?, population?, filterKey?, children?: [] } */
items: { type: Array, required: true },
/** Column definitions: [{ key: 'count'|'population', label, width }] */
columns: { type: Array, default: () => [] },
/** Currently active filter key (highlighted row) */
activeFilter: { type: String, default: null },
/** Dark mode flag */
isDark: { type: Boolean, default: false },
/** Hide the built-in header row (parent renders its own title outside LegendRows) */
hideColumnHeader: { type: Boolean, default: false },
/** Show totals footer */
showFooter: { type: Boolean, default: false },
totalCount: { type: Number, default: 0 },
totalPopulation: { type: Number, default: 0 },
fmtPop: { type: Function, default: v => String(v || 0) },
fmtCount: { type: Function, default: v => String(v || 0) },
/** Disable collapse/expand carets for tree items */
disableCollapse: { type: Boolean, default: false },
/**
* Prayer progress payload for the header bar — { covered, total, segments } from
* useLegendData(...).progress. null → no bar (every non-prayer legend). Falls back
* to inject('legendProgress') so a host that only provide()s it (the profile root,
* for the mobile sheet) still gets the bar without threading a prop.
*/
progress: { type: Object, default: null }
})
defineEmits(['filter-click'])
// Progress payload: explicit prop wins, else the provided one (may be a ref/computed).
const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// the agent 2026-09-16: the prayer legend's header says "Progress" (never "Legend").
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
// Scroll-to-selected — universal across flat-legend tabs (prayer / engagement /
// adoption / religion), mirroring SemanticTreeLegend's tree-legend scroll. When
// the active filter changes (incl. from a search/geocoder result), bring the
// matching row into view. Scoped to this component's own scroll container so
// multiple legends on a page don't fight; retries across frames since the row
// may render a frame later.
const itemsContainer = ref(null)
// Scroll EVERY scrollable ancestor of the row (mobile nests .lrg-items inside the
// sheet's .legend-content) so the row lands ~8px below the top of whatever actually
// scrolls. getBoundingClientRect deltas, NOT row.offsetTop — offsetTop is relative to
// the nearest *positioned* ancestor, so when the container is static it included the
// header height and the scroll overshot by ~3 rows (the reported bug). Walking
// parentElement stays inside the shadow DOM, so this never scrolls the HOST page the
// way row.scrollIntoView() did (regression 554bf10).
function bringRowToTop(row) {
let n = row.parentElement
while (n) {
const oy = getComputedStyle(n).overflowY
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) {
const rr = row.getBoundingClientRect(), nr = n.getBoundingClientRect()
n.scrollTop += (rr.top - nr.top) - 8
}
n = n.parentElement
}
}
let lrgScrollGen = 0
watch(() => props.activeFilter, async (filterKey) => {
if (!filterKey) return
const gen = ++lrgScrollGen // cancel earlier runs if the filter changes again
const container = itemsContainer.value
if (!container) return
await nextTick()
// Re-run ACROSS the mobile sheet's open animation (~300ms): the first search after a
// collapsed-by-default page load fires this while the legend is still expanding, so an
// early measurement is against the wrong height. Re-running as it settles corrects it;
// when the legend is already open these passes are no-ops.
const tryScroll = (attempt = 0) => {
if (gen !== lrgScrollGen) return // superseded by a newer filter
const row = container.querySelector(`[data-lrg-filter-key="${CSS.escape(String(filterKey))}"]`)
if (row) bringRowToTop(row)
if (attempt < 6) setTimeout(() => tryScroll(attempt + 1), 70)
}
tryScroll(0)
})
const collapsed = reactive({})
function toggleCollapse(key) {
collapsed[key] = !collapsed[key]
}
/**
* Dynamic grid-template-columns string driven by the `columns` prop.
* Shape: `[caret --lrg-caret-col] [name minmax(80px,1fr)] [badge auto × N] [trailing --lrg-caret-col]`.
* The caret column doubles as the legend's left padding AND the rendering
* track for every caret (title, row, child, footer). The trailing track
* mirrors its width so left/right visual padding is symmetric.
*/
const gridCols = computed(() => {
const badgeTracks = props.columns.map(() => 'auto').join(' ')
return `var(--lrg-caret-col) minmax(80px, 1fr) ${badgeTracks} var(--lrg-caret-col)`
})
</script>
<style scoped>
/* All styles injected via useShadowStyles for shadow DOM compatibility. */
</style>